diff --git a/apps/web/src/app/tournament/[id]/_common/_consts/image.ts b/apps/web/src/app/tournament/[id]/_common/_consts/image.ts new file mode 100644 index 000000000..8fc145549 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/_common/_consts/image.ts @@ -0,0 +1,2 @@ +/** 프리로드와 ProductCard의 next/image 캐시 키를 일치시키는 공통 sizes 값 */ +export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px'; diff --git a/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts new file mode 100644 index 000000000..47e461594 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts @@ -0,0 +1,54 @@ +'use client'; + +import { getImageProps } from 'next/image'; +import { useEffect, useRef } from 'react'; + +import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image'; + +/** 로딩 페이지에서는 1라운드 후보를, 매치 화면에서는 라운드별 `remainingItems`를 프리로드한다. */ +const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => { + const preloadedUrlsRef = useRef>(new Set()); + const pendingImagesRef = useRef>(new Set()); + + const urlsKey = imageUrls.filter(Boolean).join('|'); + + useEffect(() => { + if (!urlsKey) return; + + const pendingImages = pendingImagesRef.current; + + urlsKey.split('|').forEach(imageUrl => { + if (preloadedUrlsRef.current.has(imageUrl)) return; + preloadedUrlsRef.current.add(imageUrl); + + const { props } = getImageProps({ + src: imageUrl, + alt: '', + fill: true, + sizes: PRODUCT_CARD_IMAGE_SIZES, + }); + + const image = new Image(); + /** 현재 매치 이미지와 대역폭을 다투지 않도록 낮은 우선순위로 */ + image.fetchPriority = 'low'; + + if (props.sizes) image.sizes = props.sizes; + if (props.srcSet) image.srcset = props.srcSet; + + image.onload = () => { + pendingImages.delete(image); + }; + /** 실패한 URL은 제거해 다음 진입 시 재시도한다. */ + image.onerror = () => { + pendingImages.delete(image); + + preloadedUrlsRef.current.delete(imageUrl); + }; + + pendingImages.add(image); + image.src = props.src; + }); + }, [urlsKey]); +}; + +export default usePreloadMatchImages; diff --git a/apps/web/src/app/tournament/[id]/loading/page.tsx b/apps/web/src/app/tournament/[id]/loading/page.tsx index 8fb4b766e..1a4647f12 100644 --- a/apps/web/src/app/tournament/[id]/loading/page.tsx +++ b/apps/web/src/app/tournament/[id]/loading/page.tsx @@ -1,20 +1,43 @@ 'use client'; +import { useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { ROUTES } from '@/consts/route'; +import { TOURNAMENT_STATUS } from '@/consts/tournament'; +import usePreloadMatchImages from '../_common/_hooks/usePreloadMatchImages'; +import type { GetTournamentResponseT } from '../_common/_types/tournamentResponse'; import LoadingBar from './_components/LoadingBar'; import TournamentBracketAnimation from './_components/TournamentBracketAnimation'; const LOADING_DURATION_MS = 4000; +const getCandidateImageUrls = (tournament: GetTournamentResponseT | undefined) => { + if (!tournament) return []; + if (tournament.status === TOURNAMENT_STATUS.PENDING) + return tournament.pending.items.map(item => item.imageUrl); + if (tournament.status !== TOURNAMENT_STATUS.IN_PROGRESS) return []; + + return tournament.inProgress + ? tournament.inProgress.remainingItems.map(item => item.imageUrl) + : tournament.pending.items.map(item => item.imageUrl); +}; + function TournamentLoadingPage() { const router = useRouter(); const params = useParams(); + const queryClient = useQueryClient(); const tournamentId = Number(params.id); + const tournamentData = queryClient.getQueryData([ + 'tournament', + tournamentId, + ]); + + usePreloadMatchImages(getCandidateImageUrls(tournamentData)); + useEffect(() => { const timer = setTimeout(() => { router.replace(ROUTES.TOURNAMENT_MATCH(tournamentId)); diff --git a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx index d8880b33b..a9fda6844 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/ProductCard.tsx @@ -2,6 +2,7 @@ import BaseImage from '@/components/base-image'; import { Z_INDEX } from '@/consts/zIndex'; import formatPrice from '@/utils/formatPrice'; +import { PRODUCT_CARD_IMAGE_SIZES } from '../../_common/_consts/image'; import type { ProductT } from '../../_common/_types/tournament'; type ProductCardProps = ProductT & { @@ -33,7 +34,7 @@ function ProductCard({ imageUrl, name, price, isPicked, isFinal = false, onClick diff --git a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx index 312675dfd..bf417663e 100644 --- a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx +++ b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import usePreloadMatchImages from '../../_common/_hooks/usePreloadMatchImages'; import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse'; import useTournament from '../_hooks/useTournament'; import MatchSkeleton from './MatchSkeleton'; @@ -17,15 +18,18 @@ type TournamentClientProps = { function TournamentClient({ tournamentId, tournamentName, inProgress }: TournamentClientProps) { const { currentMatch, + remainingItems, roundLabel, isFinalRound, transitionStage, selectionEpoch, - isRecordingMatch, handleSelect, handleTransitionComplete, } = useTournament({ tournamentId, tournamentName, inProgress }); + // 다음 대진은 미리 알 수 없어도 후보는 알고 있다 — 라운드의 남은 아이템 이미지를 미리 받아둔다 + usePreloadMatchImages(remainingItems.map(item => item.imageUrl)); + const backgroundClassName = isFinalRound ? 'bg-gradient-to-b from-sky-blue-50 via-[#F5FCFF] to-white' : 'bg-bg-layer-basement'; @@ -44,8 +48,8 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname )}
- {/* 다음 매치는 서버 응답으로 오므로 기록 대기 동안 스켈레톤을 보여준다 */} - {isRecordingMatch || !currentMatch ? ( + {/* 기록 대기 중에는 이전 화면을 유지해 스켈레톤 깜빡임을 방지한다. */} + {!currentMatch ? ( ) : ( ; const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const router = useRouter(); const queryClient = useQueryClient(); - const { postRecordMatchMutation, isPostRecordMatchPending } = usePostRecordMatch({ + const { postRecordMatchMutation } = usePostRecordMatch({ tournamentId, onSuccess: data => { const completed = data.completed; @@ -77,15 +77,14 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const [currentMatch, setCurrentMatch] = useState( inProgress.currentMatch ); + // 해당 라운드에 남은 후보 아이템 — 다음 대진 이미지 프리로드용 (usePreloadMatchImages) + const [remainingItems, setRemainingItems] = useState(inProgress.remainingItems); // 라운드 내 진행한 매치 수 (라벨 표기용) — 라운드가 바뀌면 0 으로 초기화 const [matchIndex, setMatchIndex] = useState(0); const [transitionStage, setTransitionStage] = useState(null); // 카드 선택 락 해제용 — 매치가 바뀌지 않는 기록 실패에서 VsSection 을 remount 시켜 // 재선택을 가능하게 한다 (락은 useCardSelectionAnimation 내부 상태) const [selectionEpoch, setSelectionEpoch] = useState(0); - // 결승 기록 후 결과 페이지로 이동하는 동안 true — 라우팅이 끝나기 전에 - // 방금 고른 결승 매치가 다시 그려지는 깜빡임을 막는다 - const [isNavigatingToResult, setIsNavigatingToResult] = useState(false); // 준결승/결승 바텀시트 표시 중 재조회 없이 적용할 다음 라운드 데이터 const pendingNextRoundRef = useRef(null); @@ -104,7 +103,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { queryClient.setQueryData(['tournament', tournamentId], next); if (next.status === TOURNAMENT_STATUS.COMPLETED) { - setIsNavigatingToResult(true); router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId)); return; } @@ -112,6 +110,9 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const nextInProgress = next.inProgress; + // 바텀시트 분기보다 먼저 갱신 — 시트가 떠 있는 동안이 프리로드에 쓸 수 있는 시간이다 + setRemainingItems(nextInProgress.remainingItems); + // 라운드 전환 — 서버의 실제 다음 라운드 수 기준으로 바텀시트 판단 if (nextInProgress.currentRound !== currentRound) { const stage = getTransitionStage(nextInProgress.currentRound); @@ -167,7 +168,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { onSuccess: async data => { // 토너먼트 종료 — 캐시 정리(훅 onSuccess)까지 끝난 뒤 결과 페이지로 if (data.completed) { - setIsNavigatingToResult(true); router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId)); return; } @@ -228,15 +228,11 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { return { currentMatch, + remainingItems, roundLabel, isFinalRound, transitionStage, selectionEpoch, - /** - * 기록 요청 대기 중 — 다음 매치를 서버가 주므로 이 동안 스켈레톤을 노출한다. - * 결과 페이지로 이동하는 중에도 유지해 방금 고른 매치가 다시 보이지 않게 한다. - */ - isRecordingMatch: isPostRecordMatchPending || isNavigatingToResult, handleSelect, handleTransitionComplete, };