From eb3d942aae6aeb8007861f57cb11d87cf8958c61 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:27:25 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=ED=94=BC=EB=93=9C=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EB=AC=BC=20=EC=83=81=EC=84=B8=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=9C=84=EC=B9=98=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 탭(window.open) 대신 같은 화면에서 navigate()로 이동하도록 변경 - PostBody: 게시물 본문 클릭 시 window.open → navigate('/feed/:feedId') - PostFooter: 댓글 아이콘 클릭 시 window.open → navigate('/feed/:feedId') - FeedDetailPage: 뒤로가기 핸들러 정리 - window.close() / window.opener 체크 제거 - navigate(-1) 단일 호출로 단순화 - Feed: 피드 목록 상태 및 스크롤 위치를 sessionStorage에 캐싱하여 복원 - 게시물 상세에서 돌아올 때 API 재호출 없이 캐시 데이터로 즉시 렌더링 - 10분 TTL 초과 시 자동으로 캐시 무효화 - 스크롤 이벤트(100ms 디바운스)로 scrollY를 sessionStorage에 주기 저장 - 마운트 시 저장된 scrollY로 스크롤 위치 복원(rAF 2회) - 탭 전환 시 scrollTo(0, 0) 정상 동작 유지 - navigate(state: { initialTab }) 진입 시 캐시 무시하고 새로 로드 --- src/components/common/Post/PostBody.tsx | 5 +- src/components/common/Post/PostFooter.tsx | 4 +- src/pages/feed/Feed.tsx | 136 ++++++++++++++++++++-- src/pages/feed/FeedDetailPage.tsx | 8 +- 4 files changed, 134 insertions(+), 19 deletions(-) diff --git a/src/components/common/Post/PostBody.tsx b/src/components/common/Post/PostBody.tsx index 7d224c16..77553dc7 100644 --- a/src/components/common/Post/PostBody.tsx +++ b/src/components/common/Post/PostBody.tsx @@ -1,4 +1,5 @@ import { useRef, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import BookInfoCard from '../../feed/BookInfoCard'; import type { PostBodyProps } from '@/types/post'; import lookmore from '../../../assets/feed/lookmore.svg'; @@ -12,13 +13,13 @@ const PostBody = ({ feedId, contentUrls = [], }: PostBodyProps) => { + const navigate = useNavigate(); const hasImage = contentUrls.length > 0; const contentRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); const handlePostClick = (feedId: number) => { - // 새 탭에서 피드 상세 페이지 열기 - window.open(`/feed/${feedId}`, '_blank'); + navigate(`/feed/${feedId}`); }; useEffect(() => { diff --git a/src/components/common/Post/PostFooter.tsx b/src/components/common/Post/PostFooter.tsx index 1677c40e..f8fba6a7 100644 --- a/src/components/common/Post/PostFooter.tsx +++ b/src/components/common/Post/PostFooter.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import like from '../../../assets/feed/like.svg'; import activeLike from '../../../assets/feed/activeLike.svg'; import comment from '../../../assets/feed/comment.svg'; @@ -32,6 +33,7 @@ const PostFooter = ({ isDetail = false, onSaveToggle, }: PostFooterProps) => { + const navigate = useNavigate(); const [liked, setLiked] = useState(isLiked); const [likeCount, setLikeCount] = useState(initialLikeCount); const [saved, setSaved] = useState(isSaved); @@ -73,7 +75,7 @@ const PostFooter = ({ const handleComment = () => { if (isDetail) return; - window.open(`/feed/${feedId}`, '_blank'); + navigate(`/feed/${feedId}`); }; return ( diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index f41ab1b5..93e4dd91 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import NavBar from '../../components/common/NavBar'; import TabBar from '../../components/feed/TabBar'; import MyFeed from '../../components/feed/MyFeed'; @@ -15,11 +15,44 @@ import type { PostData } from '@/types/post'; const tabs = ['피드', '내 피드']; +const FEED_CACHE_KEY = 'feed_page_cache'; +const FEED_CACHE_TTL = 10 * 60 * 1000; + +interface FeedCache { + activeTab: string; + totalFeedPosts: PostData[]; + myFeedPosts: PostData[]; + totalNextCursor: string; + myNextCursor: string; + totalIsLast: boolean; + myIsLast: boolean; + scrollY: number; + timestamp: number; +} + +function getInitialFeedCache(): FeedCache | null { + try { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw) as FeedCache; + if (Date.now() - cache.timestamp < FEED_CACHE_TTL) return cache; + } catch { + // ignore + } + return null; +} + const Feed = () => { const navigate = useNavigate(); const location = useLocation(); const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; - const [activeTab, setActiveTab] = useState(initialTabFromState ?? tabs[0]); + + const [initialCache] = useState(getInitialFeedCache); + const shouldRestoreFromCache = initialCache !== null && !initialTabFromState; + + const [activeTab, setActiveTab] = useState( + initialTabFromState ?? (shouldRestoreFromCache ? initialCache!.activeTab : tabs[0]), + ); const { waitForToken } = useSocialLoginToken(); @@ -30,19 +63,34 @@ const Feed = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [totalFeedPosts, setTotalFeedPosts] = useState([]); + const [totalFeedPosts, setTotalFeedPosts] = useState( + shouldRestoreFromCache ? initialCache!.totalFeedPosts : [], + ); const [totalLoading, setTotalLoading] = useState(false); - const [totalNextCursor, setTotalNextCursor] = useState(''); - const [totalIsLast, setTotalIsLast] = useState(false); + const [totalNextCursor, setTotalNextCursor] = useState( + shouldRestoreFromCache ? initialCache!.totalNextCursor : '', + ); + const [totalIsLast, setTotalIsLast] = useState( + shouldRestoreFromCache ? initialCache!.totalIsLast : false, + ); - const [myFeedPosts, setMyFeedPosts] = useState([]); + const [myFeedPosts, setMyFeedPosts] = useState( + shouldRestoreFromCache ? initialCache!.myFeedPosts : [], + ); const [myLoading, setMyLoading] = useState(false); - const [myNextCursor, setMyNextCursor] = useState(''); - const [myIsLast, setMyIsLast] = useState(false); + const [myNextCursor, setMyNextCursor] = useState( + shouldRestoreFromCache ? initialCache!.myNextCursor : '', + ); + const [myIsLast, setMyIsLast] = useState(shouldRestoreFromCache ? initialCache!.myIsLast : false); const [tabLoading, setTabLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(!shouldRestoreFromCache); - const [initialLoading, setInitialLoading] = useState(true); + const cacheUsed = useRef(shouldRestoreFromCache); + const skipScrollResetRef = useRef(shouldRestoreFromCache); + const scrollRestoreRef = useRef( + shouldRestoreFromCache ? initialCache!.scrollY : null, + ); const handleSearchButton = () => { navigate('/feed/search'); @@ -133,10 +181,80 @@ const Feed = () => { }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); useEffect(() => { + if (skipScrollResetRef.current) { + skipScrollResetRef.current = false; + return; + } window.scrollTo(0, 0); }, [activeTab]); useEffect(() => { + if (scrollRestoreRef.current === null) return; + const y = scrollRestoreRef.current; + scrollRestoreRef.current = null; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo(0, y); + }); + }); + }, []); + + useEffect(() => { + let timeout: ReturnType; + const handleScroll = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (raw) { + try { + const cache = JSON.parse(raw) as FeedCache; + cache.scrollY = window.scrollY; + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore + } + } + }, 100); + }; + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + clearTimeout(timeout); + }; + }, []); + + useEffect(() => { + if (initialLoading || tabLoading || totalFeedPosts.length === 0) return; + const cache: FeedCache = { + activeTab, + totalFeedPosts, + myFeedPosts, + totalNextCursor, + myNextCursor, + totalIsLast, + myIsLast, + scrollY: window.scrollY, + timestamp: Date.now(), + }; + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + }, [ + activeTab, + totalFeedPosts, + myFeedPosts, + totalNextCursor, + myNextCursor, + totalIsLast, + myIsLast, + initialLoading, + tabLoading, + ]); + + useEffect(() => { + if (cacheUsed.current) { + cacheUsed.current = false; + return; + } + const loadFeedsWithToken = async () => { await waitForToken(); diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index 65b267ab..98baa585 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -160,13 +160,7 @@ const FeedDetailPage = () => { }; const handleBackClick = () => { - window.close(); - - if (window.opener) { - window.close(); - } else { - navigate(-1); - } + navigate(-1); }; if (loading) { From 809b507fa721b6379cd5dcf3e192de3f26362c05 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:36:17 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor(feed):=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=ED=9B=85=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Feed=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFeedCache.ts | 82 ++++++++++++ src/pages/feed/Feed.tsx | 270 ++++++++++++-------------------------- 2 files changed, 163 insertions(+), 189 deletions(-) create mode 100644 src/hooks/useFeedCache.ts diff --git a/src/hooks/useFeedCache.ts b/src/hooks/useFeedCache.ts new file mode 100644 index 00000000..06e56b0b --- /dev/null +++ b/src/hooks/useFeedCache.ts @@ -0,0 +1,82 @@ +import { useState, useEffect } from 'react'; +import type { PostData } from '@/types/post'; + +const FEED_CACHE_KEY = 'feed_page_cache'; +const FEED_CACHE_TTL = 10 * 60 * 1000; // 10분 + +export interface FeedCache { + activeTab: string; + totalFeedPosts: PostData[]; + myFeedPosts: PostData[]; + totalNextCursor: string; + myNextCursor: string; + totalIsLast: boolean; + myIsLast: boolean; + scrollY: number; + timestamp: number; +} + +type FeedCachePayload = Omit; + +function readCache(): FeedCache | null { + try { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw) as FeedCache; + if (Date.now() - cache.timestamp < FEED_CACHE_TTL) return cache; + } catch { + // ignore + } + return null; +} + +export function writeFeedCache(payload: FeedCachePayload): void { + const cache: FeedCache = { + ...payload, + scrollY: window.scrollY, + timestamp: Date.now(), + }; + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); +} + +export function useFeedCache() { + const [initialCache] = useState(readCache); + + useEffect(() => { + if (!initialCache) return; + const y = initialCache.scrollY; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.scrollTo(0, y); + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + let timeout: ReturnType; + + const handleScroll = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + try { + const raw = sessionStorage.getItem(FEED_CACHE_KEY); + if (!raw) return; + const cache = JSON.parse(raw) as FeedCache; + cache.scrollY = window.scrollY; + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore + } + }, 100); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', handleScroll); + clearTimeout(timeout); + }; + }, []); + + return { initialCache }; +} diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 93e4dd91..ff955c94 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -10,112 +10,65 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { getTotalFeeds } from '@/api/feeds/getTotalFeed'; import { getMyFeeds } from '@/api/feeds/getMyFeed'; import { useSocialLoginToken } from '@/hooks/useSocialLoginToken'; +import { useFeedCache, writeFeedCache } from '@/hooks/useFeedCache'; import { Container } from './Feed.styled'; import type { PostData } from '@/types/post'; const tabs = ['피드', '내 피드']; -const FEED_CACHE_KEY = 'feed_page_cache'; -const FEED_CACHE_TTL = 10 * 60 * 1000; - -interface FeedCache { - activeTab: string; - totalFeedPosts: PostData[]; - myFeedPosts: PostData[]; - totalNextCursor: string; - myNextCursor: string; - totalIsLast: boolean; - myIsLast: boolean; - scrollY: number; - timestamp: number; -} - -function getInitialFeedCache(): FeedCache | null { - try { - const raw = sessionStorage.getItem(FEED_CACHE_KEY); - if (!raw) return null; - const cache = JSON.parse(raw) as FeedCache; - if (Date.now() - cache.timestamp < FEED_CACHE_TTL) return cache; - } catch { - // ignore - } - return null; -} - const Feed = () => { const navigate = useNavigate(); const location = useLocation(); - const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; - - const [initialCache] = useState(getInitialFeedCache); - const shouldRestoreFromCache = initialCache !== null && !initialTabFromState; - - const [activeTab, setActiveTab] = useState( - initialTabFromState ?? (shouldRestoreFromCache ? initialCache!.activeTab : tabs[0]), - ); - const { waitForToken } = useSocialLoginToken(); + const { initialCache } = useFeedCache(); + + const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; + const isRestoringFromCache = initialCache !== null && !initialTabFromState; useEffect(() => { - if (initialTabFromState) { - navigate('.', { replace: true }); - } + if (initialTabFromState) navigate('.', { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const [activeTab, setActiveTab] = useState( + initialTabFromState ?? (isRestoringFromCache ? initialCache!.activeTab : tabs[0]), + ); + const [totalFeedPosts, setTotalFeedPosts] = useState( - shouldRestoreFromCache ? initialCache!.totalFeedPosts : [], + isRestoringFromCache ? initialCache!.totalFeedPosts : [], ); - const [totalLoading, setTotalLoading] = useState(false); const [totalNextCursor, setTotalNextCursor] = useState( - shouldRestoreFromCache ? initialCache!.totalNextCursor : '', + isRestoringFromCache ? initialCache!.totalNextCursor : '', ); const [totalIsLast, setTotalIsLast] = useState( - shouldRestoreFromCache ? initialCache!.totalIsLast : false, + isRestoringFromCache ? initialCache!.totalIsLast : false, ); + const [totalLoading, setTotalLoading] = useState(false); const [myFeedPosts, setMyFeedPosts] = useState( - shouldRestoreFromCache ? initialCache!.myFeedPosts : [], + isRestoringFromCache ? initialCache!.myFeedPosts : [], ); - const [myLoading, setMyLoading] = useState(false); const [myNextCursor, setMyNextCursor] = useState( - shouldRestoreFromCache ? initialCache!.myNextCursor : '', + isRestoringFromCache ? initialCache!.myNextCursor : '', ); - const [myIsLast, setMyIsLast] = useState(shouldRestoreFromCache ? initialCache!.myIsLast : false); + const [myIsLast, setMyIsLast] = useState(isRestoringFromCache ? initialCache!.myIsLast : false); + const [myLoading, setMyLoading] = useState(false); const [tabLoading, setTabLoading] = useState(false); - const [initialLoading, setInitialLoading] = useState(!shouldRestoreFromCache); - - const cacheUsed = useRef(shouldRestoreFromCache); - const skipScrollResetRef = useRef(shouldRestoreFromCache); - const scrollRestoreRef = useRef( - shouldRestoreFromCache ? initialCache!.scrollY : null, - ); - - const handleSearchButton = () => { - navigate('/feed/search'); - }; + const [initialLoading, setInitialLoading] = useState(!isRestoringFromCache); - const handleNoticeButton = () => { - navigate('/notice'); - }; + const skipFirstFetchRef = useRef(isRestoringFromCache); + const skipScrollResetRef = useRef(isRestoringFromCache); - const loadTotalFeeds = useCallback(async (_cursor?: string) => { + const loadTotalFeeds = useCallback(async (cursor?: string) => { try { setTotalLoading(true); - - const response = await getTotalFeeds(_cursor ? { cursor: _cursor } : undefined); - - if (_cursor) { - setTotalFeedPosts(prev => { - const existingIds = new Set(prev.map(post => post.feedId)); - const newPosts = response.data.feedList.filter(post => !existingIds.has(post.feedId)); - return [...prev, ...newPosts]; - }); - } else { - setTotalFeedPosts(response.data.feedList); - } - + const response = await getTotalFeeds(cursor ? { cursor } : undefined); + setTotalFeedPosts(prev => { + if (!cursor) return response.data.feedList; + const existingIds = new Set(prev.map(p => p.feedId)); + return [...prev, ...response.data.feedList.filter(p => !existingIds.has(p.feedId))]; + }); setTotalNextCursor(response.data.nextCursor); setTotalIsLast(response.data.isLast); } catch (error) { @@ -125,17 +78,13 @@ const Feed = () => { } }, []); - const loadMyFeeds = useCallback(async (_cursor?: string) => { + const loadMyFeeds = useCallback(async (cursor?: string) => { try { setMyLoading(true); - const response = await getMyFeeds(_cursor ? { cursor: _cursor } : undefined); - - if (_cursor) { - setMyFeedPosts(prev => [...prev, ...response.data.feedList]); - } else { - setMyFeedPosts(response.data.feedList); - } - + const response = await getMyFeeds(cursor ? { cursor } : undefined); + setMyFeedPosts(prev => + cursor ? [...prev, ...response.data.feedList] : response.data.feedList, + ); setMyNextCursor(response.data.nextCursor); setMyIsLast(response.data.isLast); } catch (error) { @@ -145,39 +94,57 @@ const Feed = () => { } }, []); + useEffect(() => { + if (skipFirstFetchRef.current) { + skipFirstFetchRef.current = false; + return; + } + + const load = async () => { + await waitForToken(); + setTabLoading(true); + try { + if (activeTab === '피드') await loadTotalFeeds(); + else await loadMyFeeds(); + } finally { + setTabLoading(false); + setInitialLoading(false); + } + }; + + load(); + }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); + const loadMoreFeeds = useCallback(() => { if (activeTab === '피드') { - if (!totalIsLast && !totalLoading && totalNextCursor) { - loadTotalFeeds(totalNextCursor); - } + if (!totalIsLast && !totalLoading && totalNextCursor) loadTotalFeeds(totalNextCursor); } else { - if (!myIsLast && !myLoading && myNextCursor) { - loadMyFeeds(myNextCursor); - } + if (!myIsLast && !myLoading && myNextCursor) loadMyFeeds(myNextCursor); } - }, [activeTab, totalIsLast, totalLoading, totalNextCursor, myIsLast, myLoading, myNextCursor]); + }, [ + activeTab, + totalIsLast, + totalLoading, + totalNextCursor, + myIsLast, + myLoading, + myNextCursor, + loadTotalFeeds, + loadMyFeeds, + ]); useEffect(() => { const handleScroll = () => { const isLoading = activeTab === '피드' ? totalLoading : myLoading; - const isLastPage = activeTab === '피드' ? totalIsLast : myIsLast; - - if (isLoading || isLastPage) return; + const isLast = activeTab === '피드' ? totalIsLast : myIsLast; + if (isLoading || isLast) return; - const scrollTop = window.pageYOffset || document.documentElement.scrollTop; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - if (scrollTop + windowHeight >= documentHeight - 200) { - loadMoreFeeds(); - } + const { scrollTop, scrollHeight, clientHeight } = document.documentElement; + if (scrollTop + clientHeight >= scrollHeight - 200) loadMoreFeeds(); }; window.addEventListener('scroll', handleScroll); - - return () => { - window.removeEventListener('scroll', handleScroll); - }; + return () => window.removeEventListener('scroll', handleScroll); }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); useEffect(() => { @@ -188,44 +155,9 @@ const Feed = () => { window.scrollTo(0, 0); }, [activeTab]); - useEffect(() => { - if (scrollRestoreRef.current === null) return; - const y = scrollRestoreRef.current; - scrollRestoreRef.current = null; - requestAnimationFrame(() => { - requestAnimationFrame(() => { - window.scrollTo(0, y); - }); - }); - }, []); - - useEffect(() => { - let timeout: ReturnType; - const handleScroll = () => { - clearTimeout(timeout); - timeout = setTimeout(() => { - const raw = sessionStorage.getItem(FEED_CACHE_KEY); - if (raw) { - try { - const cache = JSON.parse(raw) as FeedCache; - cache.scrollY = window.scrollY; - sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); - } catch { - // ignore - } - } - }, 100); - }; - window.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - window.removeEventListener('scroll', handleScroll); - clearTimeout(timeout); - }; - }, []); - useEffect(() => { if (initialLoading || tabLoading || totalFeedPosts.length === 0) return; - const cache: FeedCache = { + writeFeedCache({ activeTab, totalFeedPosts, myFeedPosts, @@ -233,10 +165,7 @@ const Feed = () => { myNextCursor, totalIsLast, myIsLast, - scrollY: window.scrollY, - timestamp: Date.now(), - }; - sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + }); }, [ activeTab, totalFeedPosts, @@ -249,59 +178,22 @@ const Feed = () => { tabLoading, ]); - useEffect(() => { - if (cacheUsed.current) { - cacheUsed.current = false; - return; - } - - const loadFeedsWithToken = async () => { - await waitForToken(); - - setTabLoading(true); - - try { - if (activeTab === '피드') { - await loadTotalFeeds(); - } else if (activeTab === '내 피드') { - await loadMyFeeds(); - } - } finally { - setTabLoading(false); - setInitialLoading(false); - } - }; - - loadFeedsWithToken(); - }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); + const isLoading = initialLoading || tabLoading; return ( navigate('/feed/search')} + rightButtonClick={() => navigate('/notice')} /> - {initialLoading || tabLoading ? ( + {isLoading ? ( + ) : activeTab === '피드' ? ( + ) : ( - <> - {activeTab === '피드' ? ( - <> - - - ) : ( - <> - - - )} - + )} From ba64e33b36755f7863d0bb4ddf5152d04c74829b Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:36:31 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20import=20=EB=88=84=EB=9D=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Post/PostFooter.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/common/Post/PostFooter.tsx b/src/components/common/Post/PostFooter.tsx index e54c96b9..ec18e5f9 100644 --- a/src/components/common/Post/PostFooter.tsx +++ b/src/components/common/Post/PostFooter.tsx @@ -9,6 +9,7 @@ import { postSaveFeed } from '@/api/feeds/postSave'; import { postFeedLike } from '@/api/feeds/postFeedLike'; import { usePreventDoubleClick } from '@/hooks/usePreventDoubleClick'; import { Container } from './PostFooter.styled'; +import { useNavigate } from 'react-router-dom'; interface PostFooterProps { likeCount: number; @@ -114,7 +115,11 @@ const PostFooter = ({
- +
{likeCount}
From d041ccaa539519f9abae9e45109146796fef148e Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:39:46 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=ED=99=9C=EC=84=B1=ED=83=AD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/feed/Feed.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index ff955c94..413fc30e 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -156,7 +156,9 @@ const Feed = () => { }, [activeTab]); useEffect(() => { - if (initialLoading || tabLoading || totalFeedPosts.length === 0) return; + const activeTabHasPosts = + activeTab === '피드' ? totalFeedPosts.length > 0 : myFeedPosts.length > 0; + if (initialLoading || tabLoading || !activeTabHasPosts) return; writeFeedCache({ activeTab, totalFeedPosts, From 6d67950e6840141affad1dc5e4bae987b272d508 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:45:41 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=20=ED=9D=B0=20=ED=99=94=EB=A9=B4=20=EA=B9=9C=EB=B9=A1?= =?UTF-8?q?=EC=9E=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로딩 중 return <>로 아무것도 렌더하지 않아 부모 Layout의 흰 배경이 노출되면서 깜빡임이 발생하던 문제 수정 - loading 상태: + LoadingSpinner를 렌더해 어두운 배경 유지 - error / !feedData 상태: 세 개로 분리된 return <> 를 하나로 통합 --- src/pages/feed/FeedDetailPage.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index 98baa585..116db5e0 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -6,6 +6,7 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import ReplyList from '@/components/common/Post/ReplyList'; import MessageInput from '@/components/today-words/MessageInput'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; import { usePopupActions } from '@/hooks/usePopupActions'; import { useReplyActions } from '@/hooks/useReplyActions'; import { getFeedDetail, type FeedDetailData } from '@/api/feeds/getFeedDetail'; @@ -164,15 +165,15 @@ const FeedDetailPage = () => { }; if (loading) { - return <>; + return ( + + + + ); } - if (error) { - return <>; - } - - if (!feedData) { - return <>; + if (error || !feedData) { + return ; } return ( From f65968e0fa67e509775b9993748e88420a4ff3a6 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:00:31 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EB=A8=B8=EC=A7=80=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/feed/Feed.tsx | 38 +------------------------------ src/pages/feed/FeedDetailPage.tsx | 14 +----------- 2 files changed, 2 insertions(+), 50 deletions(-) diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index 8cec8829..c23a676b 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -5,13 +5,13 @@ import MyFeed from '../../components/feed/MyFeed'; import TotalFeed from '../../components/feed/TotalFeed'; import MainHeader from '@/components/common/MainHeader'; import { FeedPostSkeleton, OtherFeedSkeleton } from '@/shared/ui/Skeleton'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; import writefab from '../../assets/common/writefab.svg'; import { useNavigate, useLocation } from 'react-router-dom'; import { getTotalFeeds } from '@/api/feeds/getTotalFeed'; import { getMyFeeds } from '@/api/feeds/getMyFeed'; import { useSocialLoginToken } from '@/hooks/useSocialLoginToken'; import { useFeedCache, writeFeedCache } from '@/hooks/useFeedCache'; -import { Container } from './Feed.styled'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Container, SkeletonWrapper } from './Feed.styled'; import type { PostData } from '@/types/post'; @@ -148,13 +148,6 @@ const Feed = () => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); - const handleSearchButton = () => { - navigate('/feed/search'); - }; - - const handleNoticeButton = () => { - navigate('/notice'); - }; const totalFeed = useInifinieScroll({ enabled: activeTab === '피드', @@ -224,29 +217,6 @@ const Feed = () => { tabLoading, ]); - const isLoading = initialLoading || tabLoading; - const loadFeedsWithToken = async () => { - await waitForToken(); - - setTabLoading(true); - - try { - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - - if (activeTab === '피드') { - await Promise.all([loadTotalFeeds(), minLoadingTime]); - } else if (activeTab === '내 피드') { - await Promise.all([loadMyFeeds(), minLoadingTime]); - } - } finally { - setTabLoading(false); - setInitialLoading(false); - } - }; - - loadFeedsWithToken(); - }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); - return ( { rightButtonClick={() => navigate('/notice')} /> - {isLoading ? ( - - ) : activeTab === '피드' ? ( - - ) : ( - {initialLoading || tabLoading ? ( activeTab === '내 피드' ? ( diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index 797f9d74..a8499ed9 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -6,7 +6,6 @@ import leftArrow from '../../assets/common/leftArrow.svg'; import moreIcon from '../../assets/common/more.svg'; import ReplyList from '@/components/common/Post/ReplyList'; import MessageInput from '@/components/today-words/MessageInput'; -import LoadingSpinner from '@/components/common/LoadingSpinner'; import { usePopupActions } from '@/hooks/usePopupActions'; import { useReplyActions } from '@/hooks/useReplyActions'; import { getFeedDetail, type FeedDetailData } from '@/api/feeds/getFeedDetail'; @@ -45,12 +44,8 @@ const FeedDetailPage = () => { try { setLoading(true); - const feedResponse = await getFeedDetail(Number(feedId)); const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [feedResponse, commentsResponse] = await Promise.all([ - getFeedDetail(Number(feedId)), - getComments(Number(feedId), { postType: 'FEED' }), - ]); + const feedResponse = await getFeedDetail(Number(feedId)); await minLoadingTime; setFeedData(feedResponse.data); @@ -148,9 +143,6 @@ const FeedDetailPage = () => { if (loading) { return ( - - - ); } onLeftClick={handleBackClick} @@ -171,10 +163,6 @@ const FeedDetailPage = () => { ); } - if (error) { - return <>; - } - if (error || !feedData) { return ; } From c2e7b8b62c87070c459b4a2405553128aa0f83a1 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:32:03 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20refactor=20=EB=A8=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=EC=9E=94=EC=9E=AC=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=93=B0=EA=B8=B0=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FollowerListPage: 구버전 loadUserList useCallback 제거, useInifinieScroll 복원 - GroupSearch: 고아 setTimeout·loadMore useCallback 제거, searchFirstPage 대신 useInifinieScroll 기반 searchResult 도입 - Memory: fetchPage 내 loadMemoryPosts useCallback 혼입 코드 제거, 미사용 error/loading 상태 및 LoadingSpinner import 제거 - SavePage: 구버전 loadSavedBooks/loadSavedFeeds/loadAllData 수동 페이지네이션 코드 제거 (useInifinieScroll 중복) - SearchBook: 구버전 loadMore useCallback 잔재 제거, LoadingBox import 추가 - useFeedCache: writeFeedCache의 sessionStorage.setItem을 try/catch로 감싸 쿼터 초과·프라이빗 브라우징 환경에서 예외가 피드 렌더로 전파되지 않도록 보호 --- src/hooks/useFeedCache.ts | 6 +- src/pages/feed/FollowerListPage.tsx | 53 +++-------- src/pages/groupSearch/GroupSearch.tsx | 127 ++------------------------ src/pages/memory/Memory.tsx | 63 +++++-------- src/pages/mypage/SavePage.tsx | 122 +------------------------ src/pages/searchBook/SearchBook.tsx | 109 +++++++--------------- 6 files changed, 86 insertions(+), 394 deletions(-) diff --git a/src/hooks/useFeedCache.ts b/src/hooks/useFeedCache.ts index 06e56b0b..dae8bf05 100644 --- a/src/hooks/useFeedCache.ts +++ b/src/hooks/useFeedCache.ts @@ -36,7 +36,11 @@ export function writeFeedCache(payload: FeedCachePayload): void { scrollY: window.scrollY, timestamp: Date.now(), }; - sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + try { + sessionStorage.setItem(FEED_CACHE_KEY, JSON.stringify(cache)); + } catch { + // ignore (storage quota exceeded or private browsing) + } } export function useFeedCache() { diff --git a/src/pages/feed/FollowerListPage.tsx b/src/pages/feed/FollowerListPage.tsx index 2d5e8e62..bd4821e2 100644 --- a/src/pages/feed/FollowerListPage.tsx +++ b/src/pages/feed/FollowerListPage.tsx @@ -23,44 +23,13 @@ const FollowerListPage = () => { navigate(-1); }; - const loadUserList = useCallback( - async (cursor?: string) => { - if (loading) return; - - try { - setLoading(true); - setError(null); - let response; - - const minLoadingTime = !cursor ? new Promise(resolve => setTimeout(resolve, 500)) : null; - - if (type === 'followerlist') { - if (!userId) { - setError('사용자 ID가 없습니다.'); - return; - } - const [data] = await Promise.all([ - getFollowerList(userId, { size: 10, cursor: cursor || null }), - ]); - response = data; - } else { - const [data] = await Promise.all([ - getFollowingList({ size: 10, cursor: cursor || null }), - ]); - response = data; - } - await minLoadingTime; - - let userData: FollowData[] = []; - if (type === 'followerlist') { - userData = (response.data as { followers: FollowData[] })?.followers || []; - } else { - userData = (response.data as { followings: FollowData[] })?.followings || []; - } - - if (!response || !response.data) { - setError('API 응답이 없습니다.'); - return; + const userList = useInifinieScroll({ + enabled: true, + reloadKey: `${type ?? ''}-${userId ?? ''}`, + fetchPage: async cursor => { + if (type === 'followerlist') { + if (!userId) { + return { items: [], nextCursor: null, isLast: true }; } const response = await getFollowerList(userId, { size: 10, cursor }); const total = response.data.totalFollowerCount; @@ -91,9 +60,13 @@ const FollowerListPage = () => { return ( - } onLeftClick={handleBackClick} title={title} /> + } + onLeftClick={handleBackClick} + title={title} + /> 전체 {totalCount} - {loading && userList.length === 0 ? ( + {userList.isLoading && userList.items.length === 0 ? ( {Array.from({ length: 5 }).map((_, i) => ( diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index c96ba157..4b3ebb19 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -6,12 +6,11 @@ import rightChevron from '../../assets/common/right-Chevron.svg'; import { useState, useEffect, useCallback } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; -import LoadingSpinner from '@/components/common/LoadingSpinner'; import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; -import { getSearchRooms } from '@/api/rooms/getSearchRooms'; +import { getSearchRooms, type SearchRoomItem } from '@/api/rooms/getSearchRooms'; import { useNavigate, useLocation } from 'react-router-dom'; -import { AllRoomsButton, LoadingMessage } from './GroupSearch.styled'; +import { AllRoomsButton } from './GroupSearch.styled'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { GroupCardSkeleton, RecentSearchTabsSkeleton } from '@/shared/ui/Skeleton'; import { Content } from '@/components/search/GroupSearchResult.styled'; @@ -43,7 +42,6 @@ const GroupSearch = () => { useEffect(() => { fetchRecentSearches(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -65,55 +63,6 @@ const GroupSearch = () => { } }; - const searchFirstPage = useCallback( - async ( - term: string, - sortKey: SortKey, - status: 'searching' | 'searched', - categoryParam: string, - isAllCategory: boolean = false, - keepPrevious: boolean = false, - ) => { - setIsLoading(true); - setError(null); - if (!keepPrevious) { - setRooms([]); - setNextCursor(null); - setIsLast(true); - } - - try { - const isFinalized = status === 'searched'; - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [res] = await Promise.all([ - getSearchRooms( - term.trim(), - sortKey, - undefined, - isFinalized, - categoryParam, - isAllCategory, - ), - ]); - await minLoadingTime; - if (res.isSuccess) { - const { roomList, nextCursor: nc, isLast: last } = res.data; - - setRooms(roomList); - setNextCursor(nc); - setIsLast(last); - } else { - setError(res.message || '검색 실패'); - } - } catch { - setError('네트워크 오류가 발생했습니다.'); - } finally { - setIsLoading(false); - } - }, - [], - ); - useEffect(() => { if (location.state?.allRooms) { navigate(location.pathname, { replace: true }); @@ -141,7 +90,7 @@ const GroupSearch = () => { setSearchStatus('searching'); setShowTabs(false); const id = setTimeout(() => { - searchFirstPage(trimmed, toSortKey(selectedFilter), 'searching', category, false, true); + setDebouncedSearchTerm(trimmed); }, 300); setSearchTimeoutId(id); }; @@ -180,85 +129,29 @@ const GroupSearch = () => { setCategory(''); }; - const searchStatusRef = useRef(searchStatus); - const categoryRef = useRef(category); - const selectedFilterRef = useRef(selectedFilter); - const searchTermRef = useRef(searchTerm); - - useEffect(() => { - searchStatusRef.current = searchStatus; - categoryRef.current = category; - selectedFilterRef.current = selectedFilter; - searchTermRef.current = searchTerm; - }); - - useEffect(() => { - if (searchStatus !== 'searched') return; - - const term = searchTermRef.current.trim(); - const currentCategory = categoryRef.current; - const isAllCategory = !term && currentCategory === ''; - - searchFirstPage( - term, - toSortKey(selectedFilterRef.current), - 'searched', - currentCategory, - isAllCategory, - true, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchStatus, searchTerm]); - - useEffect(() => { - if (searchStatusRef.current !== 'searched') return; - - const term = searchTermRef.current.trim(); - const isAllCategory = !term && category === ''; - - searchFirstPage(term, toSortKey(selectedFilter), 'searched', category, isAllCategory, true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedFilter, category]); - useEffect(() => { if (searchStatus === 'searched') { setDebouncedSearchTerm(searchTerm.trim()); } }, [searchStatus, searchTerm]); - const id = setTimeout(() => { - const currentCategory = categoryRef.current; - searchFirstPage(term, toSortKey(selectedFilter), 'searching', currentCategory, false, true); - }, 300); - setSearchTimeoutId(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchTerm, searchStatus, selectedFilter]); - - const loadMore = useCallback(async () => { - const trimmedTerm = searchTerm.trim(); - const isAllCategory = !trimmedTerm && category === ''; - if ((!isAllCategory && !trimmedTerm) || !nextCursor || isLast || isLoadingMore) return; - try { - setIsLoadingMore(true); + const searchResult = useInifinieScroll({ + enabled: searchStatus === 'searched' || (searchStatus === 'searching' && !!debouncedSearchTerm), + reloadKey: `${debouncedSearchTerm}-${selectedFilter}-${category}-${searchStatus}`, + fetchPage: async cursor => { const isFinalized = searchStatus === 'searched'; - const isAllCategory = !queryTerm && category === ''; - if (searchStatus === 'searching' && !queryTerm) { - return { items: [], nextCursor: null, isLast: true }; - } - + const isAllCategory = !debouncedSearchTerm && category === ''; const res = await getSearchRooms( - queryTerm, + debouncedSearchTerm, toSortKey(selectedFilter), cursor ?? undefined, isFinalized, category, isAllCategory, ); - if (!res.isSuccess) { throw new Error(res.message || '검색 실패'); } - return { items: res.data.roomList, nextCursor: res.data.nextCursor, @@ -317,7 +210,7 @@ const GroupSearch = () => { {searchStatus !== 'idle' ? ( <> - {isLoading && rooms.length === 0 ? ( + {searchResult.isLoading && searchResult.items.length === 0 ? ( {Array.from({ length: 5 }).map((_, i) => ( diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index f2ef9590..98735e08 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -6,7 +6,6 @@ import MemoryContent from '../../components/memory/MemoryContent/MemoryContent'; import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddButton'; import Snackbar from '../../components/common/Modal/Snackbar'; import GlobalCommentBottomSheet from '../../components/common/CommentBottomSheet/GlobalCommentBottomSheet'; -import LoadingSpinner from '../../components/common/LoadingSpinner'; import { useCommentBottomSheetStore } from '@/stores/commentBottomSheetStore'; import { useInifinieScroll } from '@/hooks/useInifinieScroll'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; @@ -89,9 +88,6 @@ const Memory = () => { } }, [location.search]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [showUploadProgress, setShowUploadProgress] = useState(false); const [roomCompleted, setRoomCompleted] = useState(false); @@ -109,37 +105,26 @@ const Memory = () => { return { items: [], nextCursor: null, isLast: true }; } - const loadMemoryPosts = useCallback(async () => { - if (!roomId) { - return; - } - setError(null); - setLoading(true); - - try { - const params: GetMemoryPostsParams = { - roomId: parseInt(roomId, 10), - type: activeTab === 'group' ? 'group' : 'mine', - cursor, - }; - - if (activeTab === 'group') { - params.sort = selectedSort; - } + try { + const params: GetMemoryPostsParams = { + roomId: parseInt(roomId, 10), + type: activeTab === 'group' ? 'group' : 'mine', + cursor, + }; - if (activeFilter === 'overall') { - params.isOverview = true; - } else if (selectedPageRange) { - params.pageStart = selectedPageRange.start; - params.pageEnd = selectedPageRange.end; - params.isPageFilter = true; - } + if (activeTab === 'group') { + params.sort = selectedSort; + } - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [response] = await Promise.all([getMemoryPosts(params), minLoadingTime]); + if (activeFilter === 'overall') { + params.isOverview = true; + } else if (selectedPageRange) { + params.pageStart = selectedPageRange.start; + params.pageEnd = selectedPageRange.end; + params.isPageFilter = true; + } - if (response.isSuccess) { - const convertedRecords = response.data.postList.map(convertPostToRecord); + const [response] = await Promise.all([getMemoryPosts(params)]); if (!response.isSuccess) { throw new Error(response.message || '기록을 불러오는 중 오류가 발생했습니다.'); @@ -167,12 +152,8 @@ const Memory = () => { } throw new Error('기록을 불러오는 중 오류가 발생했습니다.'); } - - setError('기록을 불러오는 중 오류가 발생했습니다.'); - } finally { - setLoading(false); - } - }, [roomId, activeTab, selectedSort, activeFilter, selectedPageRange]); + }, + }); useEffect(() => { const checkRoomStatus = async () => { @@ -217,11 +198,11 @@ const Memory = () => { if (location.state?.newRecord) { const newRecord = location.state.newRecord as Record; setShowUploadProgress(true); - setRecordItems(prev => [newRecord, ...prev]); + recordsList.setItems(prev => [newRecord, ...prev]); navigate(location.pathname, { replace: true }); } - }, [location.state, navigate, location.pathname, setRecordItems]); + }, [location.state, navigate, location.pathname]); const currentRecords = useMemo(() => recordsList.items, [recordsList.items]); @@ -316,7 +297,7 @@ const Memory = () => { - {loading ? ( + {recordsList.isLoading ? ( diff --git a/src/pages/mypage/SavePage.tsx b/src/pages/mypage/SavePage.tsx index a953466b..2958d944 100644 --- a/src/pages/mypage/SavePage.tsx +++ b/src/pages/mypage/SavePage.tsx @@ -69,121 +69,6 @@ const SavePage = () => { navigate('/mypage'); }; - const loadSavedBooks = useCallback(async (cursor: string | null = null) => { - try { - setBookLoading(true); - const response = await getSavedBooksInMy(cursor); - - if (cursor === null) { - setSavedBooks(response.data.bookList); - } else { - setSavedBooks(prev => [...prev, ...response.data.bookList]); - } - - setBookNextCursor(response.data.nextCursor); - setBookIsLast(response.data.isLast); - } catch (error) { - console.error('저장된 책 목록 로드 실패:', error); - } finally { - setBookLoading(false); - } - }, []); - - const loadSavedFeeds = useCallback(async (cursor: string | null = null) => { - try { - setFeedLoading(true); - const response = await getSavedFeedsInMy(cursor); - - if (cursor === null) { - setSavedFeeds(response.data.feedList); - } else { - setSavedFeeds(prev => [...prev, ...response.data.feedList]); - } - - setFeedNextCursor(response.data.nextCursor); - setFeedIsLast(response.data.isLast); - } catch (error) { - console.error('저장된 피드 로드 실패:', error); - } finally { - setFeedLoading(false); - } - }, []); - - const loadMoreBooks = useCallback(async () => { - if (!bookNextCursor || bookIsLast || bookLoading) return; - - try { - await loadSavedBooks(bookNextCursor); - } catch (error) { - console.error('책 추가 로드 실패:', error); - } - }, [bookNextCursor, bookIsLast, bookLoading, loadSavedBooks]); - - const lastBookElementCallback = useCallback( - (node: HTMLDivElement | null) => { - if (bookLoading || bookIsLast) return; - - if (node) { - const observer = new IntersectionObserver(entries => { - if (entries[0].isIntersecting && !bookLoading && !bookIsLast) { - loadMoreBooks(); - } - }); - - observer.observe(node); - } - }, - [bookLoading, bookIsLast, loadMoreBooks], - ); - - useEffect(() => { - const loadAllData = async () => { - try { - setInitialLoading(true); - - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [feedsResponse, booksResponse] = await Promise.all([ - getSavedFeedsInMy(null), - getSavedBooksInMy(), - ]); - await minLoadingTime; - - setSavedFeeds(feedsResponse.data.feedList); - setFeedNextCursor(feedsResponse.data.nextCursor); - setFeedIsLast(feedsResponse.data.isLast); - - setSavedBooks(booksResponse.data.bookList); - setBookNextCursor(booksResponse.data.nextCursor); - setBookIsLast(booksResponse.data.isLast); - } catch (error) { - console.error('초기 데이터 로드 실패:', error); - } finally { - setInitialLoading(false); - } - }; - - loadAllData(); - }, []); - - useEffect(() => { - const observer = new IntersectionObserver( - entries => { - entries.forEach(entry => { - if (entry.isIntersecting && !feedIsLast && !feedLoading && feedNextCursor) { - loadSavedFeeds(feedNextCursor); - } - }); - }, - { threshold: 0.1 }, - ); - - if (feedObserverRef.current) { - observer.observe(feedObserverRef.current); - } - - return () => observer.disconnect(); - }, [feedIsLast, feedLoading, feedNextCursor, loadSavedFeeds]); - useEffect(() => { window.scrollTo(0, 0); }, [activeTab]); @@ -230,7 +115,7 @@ const SavePage = () => { title="저장" /> - {initialLoading ? ( + {showInitialLoading ? ( activeTab === '피드' ? ( {Array.from({ length: 3 }).map((_, index) => ( @@ -291,7 +176,10 @@ const SavePage = () => { handleSaveToggle(book.isbn)}> - {book.isSaved + {book.isSaved ))} diff --git a/src/pages/searchBook/SearchBook.tsx b/src/pages/searchBook/SearchBook.tsx index 804e6b84..dfedd309 100644 --- a/src/pages/searchBook/SearchBook.tsx +++ b/src/pages/searchBook/SearchBook.tsx @@ -21,6 +21,7 @@ import { EmptyTitle, EmptySubText, FeedPostContainer, + LoadingBox, } from './SearchBook.styled'; import { useNavigate, useParams } from 'react-router-dom'; import leftArrow from '../../assets/common/leftArrow.svg'; @@ -103,64 +104,18 @@ const SearchBook = () => { fetchBookDetail(); }, [isbn]); - const loadFirstFeeds = useCallback(async () => { - if (!isbn) return; - try { - setIsLoadingFeeds(true); - setFeeds([]); - setNextCursor(null); - setIsLast(true); - - const minLoadingTime = new Promise(resolve => setTimeout(resolve, 500)); - const [res] = await Promise.all([ - getFeedsByIsbn(isbn, toFeedSort(selectedFilter), null), - ]); - await minLoadingTime; - if (res.isSuccess) { - setFeeds(res.data.feeds); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } - } catch { - // no-op - } finally { - setIsLoadingFeeds(false); - } - }, [isbn, selectedFilter]); - - useEffect(() => { - loadFirstFeeds(); - }, [loadFirstFeeds]); - - const loadMore = useCallback(async () => { - if (!isbn || !nextCursor || isLast || isLoadingMore) return; - try { - setIsLoadingMore(true); - const res = await getFeedsByIsbn(isbn, toFeedSort(selectedFilter), nextCursor); - if (res.isSuccess) { - setFeeds(prev => [...prev, ...res.data.feeds]); - setNextCursor(res.data.nextCursor); - setIsLast(res.data.isLast); - } - } catch { - // no-op - } finally { - setIsLoadingMore(false); - } - }, [isbn, nextCursor, isLast, isLoadingMore, selectedFilter]); - - const lastFeedElementCallback = 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); + const feeds = useInifinieScroll({ + enabled: !!isbn, + reloadKey: `${isbn ?? ''}-${selectedFilter}`, + fetchPage: async cursor => { + if (!isbn) return { items: [], nextCursor: null, isLast: true }; + const res = await getFeedsByIsbn(isbn, toFeedSort(selectedFilter), cursor ?? null); + if (!res.isSuccess) throw new Error(res.message || '피드 로드 실패'); + return { + items: res.data.feeds, + nextCursor: res.data.nextCursor, + isLast: res.data.isLast, + }; }, rootMargin: '100px 0px', threshold: 0.1, @@ -245,9 +200,7 @@ const SearchBook = () => {
-
- {error} -
+
{error}
); } @@ -275,21 +228,21 @@ const SearchBook = () => { {bookDetail.description} - - - 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} - 오른쪽 화살표 아이콘 - - - - 피드에 글쓰기 더하기 아이콘 - - - 저장 버튼 - - - - + + + 모집중인 모임방 {recruitingRoomsData?.totalRoomCount || 0}개{' '} + 오른쪽 화살표 아이콘 + + + + 피드에 글쓰기 더하기 아이콘 + + + 저장 버튼 + + + + )} @@ -302,13 +255,13 @@ const SearchBook = () => { setSelectedFilter={filter => setSelectedFilter(filter as (typeof FILTER)[number])} /> - {isLoadingFeeds && feeds.length === 0 ? ( + {feeds.isLoading && feeds.items.length === 0 ? ( {Array.from({ length: 3 }).map((_, i) => ( ))} - ) : feeds.length > 0 ? ( + ) : feeds.items.length > 0 ? ( {feeds.items.map(post => (
From 2e5adf9161d76412e5c5b56655fd67244444a05a Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:43:25 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useFeedCache.ts | 7 +- src/hooks/useInifinieScroll.ts | 20 +++- src/pages/feed/Feed.tsx | 182 ++++++------------------------ src/pages/feed/FeedDetailPage.tsx | 9 +- 4 files changed, 66 insertions(+), 152 deletions(-) diff --git a/src/hooks/useFeedCache.ts b/src/hooks/useFeedCache.ts index dae8bf05..b96a39db 100644 --- a/src/hooks/useFeedCache.ts +++ b/src/hooks/useFeedCache.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { PostData } from '@/types/post'; const FEED_CACHE_KEY = 'feed_page_cache'; @@ -43,11 +43,12 @@ export function writeFeedCache(payload: FeedCachePayload): void { } } -export function useFeedCache() { +export function useFeedCache(options?: { disableRestore?: boolean }) { const [initialCache] = useState(readCache); + const disableRestoreRef = useRef(options?.disableRestore ?? false); useEffect(() => { - if (!initialCache) return; + if (disableRestoreRef.current || !initialCache) return; const y = initialCache.scrollY; requestAnimationFrame(() => { requestAnimationFrame(() => { diff --git a/src/hooks/useInifinieScroll.ts b/src/hooks/useInifinieScroll.ts index c8033b53..8093efda 100644 --- a/src/hooks/useInifinieScroll.ts +++ b/src/hooks/useInifinieScroll.ts @@ -15,6 +15,9 @@ interface UseInifinieScrollOptions { rootRef?: RefObject; rootMargin?: string; threshold?: number; + initialItems?: T[]; + initialCursor?: string | null; + initialIsLast?: boolean; } export const useInifinieScroll = ({ @@ -25,6 +28,9 @@ export const useInifinieScroll = ({ rootRef, rootMargin = '200px 0px', threshold = 0, + initialItems, + initialCursor, + initialIsLast, }: UseInifinieScrollOptions) => { const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); @@ -36,6 +42,7 @@ export const useInifinieScroll = ({ const sentinelRef = useRef(null); const isFetchingRef = useRef(false); const fetchPageRef = useRef(fetchPage); + const initializedRef = useRef(false); useEffect(() => { fetchPageRef.current = fetchPage; @@ -89,11 +96,21 @@ export const useInifinieScroll = ({ useEffect(() => { if (!enabled) return; + + if (!initializedRef.current && initialItems && initialItems.length > 0) { + initializedRef.current = true; + setItems(initialItems); + setNextCursor(initialCursor ?? null); + setIsLast(initialIsLast ?? false); + return; + } + + initializedRef.current = true; setItems([]); setNextCursor(null); setIsLast(false); void loadFirstPage(); - }, [enabled, reloadKey, loadFirstPage]); + }, [enabled, reloadKey, loadFirstPage]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!enabled || !sentinelRef.current) return; @@ -115,6 +132,7 @@ export const useInifinieScroll = ({ return { items, setItems, + nextCursor, isLast, isLoading, isLoadingMore, diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index c23a676b..005a0090 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import NavBar from '../../components/common/NavBar'; import TabBar from '../../components/feed/TabBar'; import MyFeed from '../../components/feed/MyFeed'; @@ -22,9 +22,9 @@ const Feed = () => { const navigate = useNavigate(); const location = useLocation(); const { waitForToken } = useSocialLoginToken(); - const { initialCache } = useFeedCache(); const initialTabFromState = (location.state as { initialTab?: string } | null)?.initialTab; + const { initialCache } = useFeedCache({ disableRestore: !!initialTabFromState }); const isRestoringFromCache = initialCache !== null && !initialTabFromState; useEffect(() => { @@ -36,119 +36,8 @@ const Feed = () => { initialTabFromState ?? (isRestoringFromCache ? initialCache!.activeTab : tabs[0]), ); - const [totalFeedPosts, setTotalFeedPosts] = useState( - isRestoringFromCache ? initialCache!.totalFeedPosts : [], - ); - const [totalNextCursor, setTotalNextCursor] = useState( - isRestoringFromCache ? initialCache!.totalNextCursor : '', - ); - const [totalIsLast, setTotalIsLast] = useState( - isRestoringFromCache ? initialCache!.totalIsLast : false, - ); - const [totalLoading, setTotalLoading] = useState(false); - - const [myFeedPosts, setMyFeedPosts] = useState( - isRestoringFromCache ? initialCache!.myFeedPosts : [], - ); - const [myNextCursor, setMyNextCursor] = useState( - isRestoringFromCache ? initialCache!.myNextCursor : '', - ); - const [myIsLast, setMyIsLast] = useState(isRestoringFromCache ? initialCache!.myIsLast : false); - const [myLoading, setMyLoading] = useState(false); - - const [tabLoading, setTabLoading] = useState(false); - const [initialLoading, setInitialLoading] = useState(!isRestoringFromCache); - - const skipFirstFetchRef = useRef(isRestoringFromCache); const skipScrollResetRef = useRef(isRestoringFromCache); - const loadTotalFeeds = useCallback(async (cursor?: string) => { - try { - setTotalLoading(true); - const response = await getTotalFeeds(cursor ? { cursor } : undefined); - setTotalFeedPosts(prev => { - if (!cursor) return response.data.feedList; - const existingIds = new Set(prev.map(p => p.feedId)); - return [...prev, ...response.data.feedList.filter(p => !existingIds.has(p.feedId))]; - }); - setTotalNextCursor(response.data.nextCursor); - setTotalIsLast(response.data.isLast); - } catch (error) { - console.error('전체 피드 로드 실패:', error); - } finally { - setTotalLoading(false); - } - }, []); - - const loadMyFeeds = useCallback(async (cursor?: string) => { - try { - setMyLoading(true); - const response = await getMyFeeds(cursor ? { cursor } : undefined); - setMyFeedPosts(prev => - cursor ? [...prev, ...response.data.feedList] : response.data.feedList, - ); - setMyNextCursor(response.data.nextCursor); - setMyIsLast(response.data.isLast); - } catch (error) { - console.error('내 피드 로드 실패:', error); - } finally { - setMyLoading(false); - } - }, []); - - useEffect(() => { - if (skipFirstFetchRef.current) { - skipFirstFetchRef.current = false; - return; - } - - const load = async () => { - await waitForToken(); - setTabLoading(true); - try { - if (activeTab === '피드') await loadTotalFeeds(); - else await loadMyFeeds(); - } finally { - setTabLoading(false); - setInitialLoading(false); - } - }; - - load(); - }, [activeTab, waitForToken, loadTotalFeeds, loadMyFeeds]); - - const loadMoreFeeds = useCallback(() => { - if (activeTab === '피드') { - if (!totalIsLast && !totalLoading && totalNextCursor) loadTotalFeeds(totalNextCursor); - } else { - if (!myIsLast && !myLoading && myNextCursor) loadMyFeeds(myNextCursor); - } - }, [ - activeTab, - totalIsLast, - totalLoading, - totalNextCursor, - myIsLast, - myLoading, - myNextCursor, - loadTotalFeeds, - loadMyFeeds, - ]); - - useEffect(() => { - const handleScroll = () => { - const isLoading = activeTab === '피드' ? totalLoading : myLoading; - const isLast = activeTab === '피드' ? totalIsLast : myIsLast; - if (isLoading || isLast) return; - - const { scrollTop, scrollHeight, clientHeight } = document.documentElement; - if (scrollTop + clientHeight >= scrollHeight - 200) loadMoreFeeds(); - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); - const totalFeed = useInifinieScroll({ enabled: activeTab === '피드', reloadKey: activeTab, @@ -166,6 +55,9 @@ const Feed = () => { const newPosts = next.filter(post => !existingIds.has(post.feedId)); return [...prev, ...newPosts]; }, + initialItems: isRestoringFromCache ? initialCache!.totalFeedPosts : undefined, + initialCursor: isRestoringFromCache ? initialCache!.totalNextCursor || null : undefined, + initialIsLast: isRestoringFromCache ? initialCache!.totalIsLast : undefined, }); const myFeed = useInifinieScroll({ @@ -180,6 +72,9 @@ const Feed = () => { isLast: response.data.isLast, }; }, + initialItems: isRestoringFromCache ? initialCache!.myFeedPosts : undefined, + initialCursor: isRestoringFromCache ? initialCache!.myNextCursor || null : undefined, + initialIsLast: isRestoringFromCache ? initialCache!.myIsLast : undefined, }); useEffect(() => { @@ -193,28 +88,25 @@ const Feed = () => { const currentFeed = activeTab === '피드' ? totalFeed : myFeed; useEffect(() => { - const activeTabHasPosts = - activeTab === '피드' ? totalFeedPosts.length > 0 : myFeedPosts.length > 0; - if (initialLoading || tabLoading || !activeTabHasPosts) return; + const hasItems = totalFeed.items.length > 0 || myFeed.items.length > 0; + if (!hasItems) return; writeFeedCache({ activeTab, - totalFeedPosts, - myFeedPosts, - totalNextCursor, - myNextCursor, - totalIsLast, - myIsLast, + totalFeedPosts: totalFeed.items, + myFeedPosts: myFeed.items, + totalNextCursor: totalFeed.nextCursor ?? '', + myNextCursor: myFeed.nextCursor ?? '', + totalIsLast: totalFeed.isLast, + myIsLast: myFeed.isLast, }); }, [ activeTab, - totalFeedPosts, - myFeedPosts, - totalNextCursor, - myNextCursor, - totalIsLast, - myIsLast, - initialLoading, - tabLoading, + totalFeed.items, + myFeed.items, + totalFeed.nextCursor, + myFeed.nextCursor, + totalFeed.isLast, + myFeed.isLast, ]); return ( @@ -225,7 +117,7 @@ const Feed = () => { rightButtonClick={() => navigate('/notice')} /> - {initialLoading || tabLoading ? ( + {currentFeed.isLoading ? ( activeTab === '내 피드' ? ( ) : ( @@ -238,23 +130,19 @@ const Feed = () => { ) : ( <> {activeTab === '피드' ? ( - <> - - + ) : ( - <> - - + )} {!currentFeed.isLast &&
} {currentFeed.isLoadingMore && } diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index a8499ed9..ba4daaa5 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -164,7 +164,14 @@ const FeedDetailPage = () => { } if (error || !feedData) { - return ; + return ( + + } + onLeftClick={handleBackClick} + /> + + ); } return ( From ef8db6cfd5fd639168de26c6bfed609a83feed22 Mon Sep 17 00:00:00 2001 From: Ji Ho June <129824629+ho0010@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:53:19 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EB=B3=91=ED=95=A9=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/memory/Memory.tsx | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index 0326e42f..16c332d2 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -111,19 +111,6 @@ const Memory = () => { type: activeTab === 'group' ? 'group' : 'mine', cursor, }; - const loadMemoryPosts = useCallback(async () => { - if (!roomId) { - return; - } - - if (activeFilter === 'page' && !selectedPageRange) { - return; - } - - setError(null); - setLoading(true); - - if (activeTab === 'group') { params.sort = selectedSort; @@ -288,13 +275,9 @@ const Memory = () => { const handleRecordDelete = useCallback( (id: string) => { - if (activeTab === 'group') { - setGroupRecords(prev => prev.filter(r => r.id !== id)); - } else { - setMyRecords(prev => prev.filter(r => r.id !== id)); - } + recordsList.setItems(prev => prev.filter(r => r.id !== id)); }, - [activeTab], + [recordsList], ); const readingProgress = totalPages > 0 ? Math.round((currentUserPage / totalPages) * 100) : 0;