(null);
@@ -112,6 +114,9 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
const nextInProgress = next.inProgress;
+ // 바텀시트 분기보다 먼저 갱신 — 시트가 떠 있는 동안이 프리로드에 쓸 수 있는 시간이다
+ setRemainingItems(nextInProgress.remainingItems);
+
// 라운드 전환 — 서버의 실제 다음 라운드 수 기준으로 바텀시트 판단
if (nextInProgress.currentRound !== currentRound) {
const stage = getTransitionStage(nextInProgress.currentRound);
@@ -228,6 +233,7 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
return {
currentMatch,
+ remainingItems,
roundLabel,
isFinalRound,
transitionStage,
From 999b4d66400459e9e1b9fac7ad14e4385f988c3f Mon Sep 17 00:00:00 2001
From: kanghaeun <145974230+kanghaeun@users.noreply.github.com>
Date: Tue, 18 Aug 2026 04:32:56 +0900
Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A7=A4=EC=B9=98=20=EC=A0=84?=
=?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4?=
=?UTF-8?q?=EC=9D=B4=20=EB=B2=88=EC=A9=8D=EC=9D=B4=EB=8A=94=20=EA=B9=9C?=
=?UTF-8?q?=EB=B9=A1=EC=9E=84=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../[id]/match/_components/TournamentClient.tsx | 5 ++---
.../tournament/[id]/match/_hooks/useTournament.ts | 12 +-----------
2 files changed, 3 insertions(+), 14 deletions(-)
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 2e4f57ba3..bd14fcc6a 100644
--- a/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx
+++ b/apps/web/src/app/tournament/[id]/match/_components/TournamentClient.tsx
@@ -23,7 +23,6 @@ function TournamentClient({ tournamentId, tournamentName, inProgress }: Tourname
isFinalRound,
transitionStage,
selectionEpoch,
- isRecordingMatch,
handleSelect,
handleTransitionComplete,
} = useTournament({ tournamentId, tournamentName, inProgress });
@@ -49,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;
@@ -85,9 +85,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
// 카드 선택 락 해제용 — 매치가 바뀌지 않는 기록 실패에서 VsSection 을 remount 시켜
// 재선택을 가능하게 한다 (락은 useCardSelectionAnimation 내부 상태)
const [selectionEpoch, setSelectionEpoch] = useState(0);
- // 결승 기록 후 결과 페이지로 이동하는 동안 true — 라우팅이 끝나기 전에
- // 방금 고른 결승 매치가 다시 그려지는 깜빡임을 막는다
- const [isNavigatingToResult, setIsNavigatingToResult] = useState(false);
// 준결승/결승 바텀시트 표시 중 재조회 없이 적용할 다음 라운드 데이터
const pendingNextRoundRef = useRef(null);
@@ -106,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;
}
@@ -172,7 +168,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
onSuccess: async data => {
// 토너먼트 종료 — 캐시 정리(훅 onSuccess)까지 끝난 뒤 결과 페이지로
if (data.completed) {
- setIsNavigatingToResult(true);
router.replace(ROUTES.TOURNAMENT_RESULT(tournamentId));
return;
}
@@ -238,11 +233,6 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => {
isFinalRound,
transitionStage,
selectionEpoch,
- /**
- * 기록 요청 대기 중 — 다음 매치를 서버가 주므로 이 동안 스켈레톤을 노출한다.
- * 결과 페이지로 이동하는 중에도 유지해 방금 고른 매치가 다시 보이지 않게 한다.
- */
- isRecordingMatch: isPostRecordMatchPending || isNavigatingToResult,
handleSelect,
handleTransitionComplete,
};
From ee72a680391fe355e3b7f0577a192bf04d7d388a Mon Sep 17 00:00:00 2001
From: kanghaeun <145974230+kanghaeun@users.noreply.github.com>
Date: Tue, 18 Aug 2026 05:22:53 +0900
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=20=ED=8E=98?=
=?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=201=EB=9D=BC=EC=9A=B4?=
=?UTF-8?q?=EB=93=9C=20=ED=9B=84=EB=B3=B4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?=
=?UTF-8?q?=ED=94=84=EB=A6=AC=EB=A1=9C=EB=93=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../tournament/[id]/_common/_consts/image.ts | 2 ++
.../_hooks/usePreloadMatchImages.ts | 17 ++------------
.../src/app/tournament/[id]/loading/page.tsx | 23 +++++++++++++++++++
.../[id]/match/_components/ProductCard.tsx | 2 +-
.../match/_components/TournamentClient.tsx | 2 +-
.../tournament/[id]/match/_consts/image.ts | 9 --------
6 files changed, 29 insertions(+), 26 deletions(-)
create mode 100644 apps/web/src/app/tournament/[id]/_common/_consts/image.ts
rename apps/web/src/app/tournament/[id]/{match => _common}/_hooks/usePreloadMatchImages.ts (58%)
delete mode 100644 apps/web/src/app/tournament/[id]/match/_consts/image.ts
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]/match/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
similarity index 58%
rename from apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts
rename to apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
index fb012cdfd..f21d4e88e 100644
--- a/apps/web/src/app/tournament/[id]/match/_hooks/usePreloadMatchImages.ts
+++ b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
@@ -5,24 +5,11 @@ import { useEffect, useRef } from 'react';
import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image';
-/**
- * 다음 대진은 기록 응답(nextMatch)으로만 알 수 있어 미리 조회할 수 없다.
- * 대신 그 라운드에 남은 아이템(remainingItems)은 이미 알고 있으므로,
- * 라운드 진입 시점에 후보 이미지를 전부 브라우저 캐시에 올려둔다.
- * → 어떤 조합이 나와도 스켈레톤이 걷힐 때 이미 받아둔 이미지가 그려진다.
- *
- * 캐시 키는 URL 문자열이고, 실제 렌더는 원본 URL 이 아니라
- * `/_next/image?url=...&w=...&q=75` 를 요청한다. 그래서 원본 URL 로 프리로드하면
- * 키가 어긋나 캐시 미스가 된다 — getImageProps 로 렌더와 동일한 srcSet/sizes 를 얻어
- * 브라우저가 렌더 때와 같은 후보를 고르게 한다.
- */
+/** 로딩 페이지에서는 1라운드 후보를, 매치 화면에서는 라운드별 `remainingItems`를 프리로드한다. */
const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => {
- /** 이미 요청한 URL — 라운드가 바뀌어도 중복 요청하지 않는다 */
const preloadedUrlsRef = useRef>(new Set());
- /** 로드 완료 전 GC 로 요청이 취소되지 않도록 인스턴스를 붙잡아 둔다 */
const pendingImagesRef = useRef>(new Set());
- /** 배열 참조가 매 렌더 바뀌므로 내용 기준으로 의존성을 만든다 */
const urlsKey = imageUrls.filter(Boolean).join('|');
useEffect(() => {
@@ -57,7 +44,7 @@ const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => {
});
return () => {
- /** 언마운트 시 진행 중인 프리로드는 정리 — 남은 요청은 브라우저가 취소한다 */
+ /** 언마운트 시 보관 중인 이미지 참조와 이벤트 핸들러 정리 */
pendingImages.forEach(image => {
image.onload = null;
image.onerror = null;
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 3a93a3b50..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,8 +2,8 @@ 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';
-import { PRODUCT_CARD_IMAGE_SIZES } from '../_consts/image';
type ProductCardProps = ProductT & {
isPicked?: boolean;
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 bd14fcc6a..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,7 +1,7 @@
'use client';
+import usePreloadMatchImages from '../../_common/_hooks/usePreloadMatchImages';
import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse';
-import usePreloadMatchImages from '../_hooks/usePreloadMatchImages';
import useTournament from '../_hooks/useTournament';
import MatchSkeleton from './MatchSkeleton';
import RoundBadge from './RoundBadge';
diff --git a/apps/web/src/app/tournament/[id]/match/_consts/image.ts b/apps/web/src/app/tournament/[id]/match/_consts/image.ts
deleted file mode 100644
index fd2e409e2..000000000
--- a/apps/web/src/app/tournament/[id]/match/_consts/image.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * 매치 상품 카드 이미지의 `sizes`.
- *
- * next/image 는 이 값과 기기 DPR 로 srcset 후보 중 하나를 골라
- * `/_next/image?url=...&w=<선택된 폭>&q=75` 를 요청한다.
- * 프리로드가 실제 렌더와 같은 URL 을 받아오려면(= 브라우저 캐시 키 일치)
- * ProductCard 와 프리로드 훅이 반드시 같은 값을 써야 해서 상수로 뽑았다.
- */
-export const PRODUCT_CARD_IMAGE_SIZES = '(max-width: 480px) 45vw, 200px';
From 08ee95df2a7ea9ffa5c57179db1a32fb83e06deb Mon Sep 17 00:00:00 2001
From: kanghaeun <145974230+kanghaeun@users.noreply.github.com>
Date: Tue, 18 Aug 2026 18:19:31 +0900
Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=ED=94=84=EB=A6=AC=EB=A1=9C=EB=93=9C?=
=?UTF-8?q?=20cleanup=20=EC=9D=B4=20=EC=A7=84=ED=96=89=20=EC=A4=91?=
=?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=81=8A=EA=B3=A0=20?=
=?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=A5=BC=20=EB=A7=89=EB=8D=98=20?=
=?UTF-8?q?=EB=AC=B8=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../_common/_hooks/usePreloadMatchImages.ts | 23 ++++++++-----------
1 file changed, 10 insertions(+), 13 deletions(-)
diff --git a/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
index f21d4e88e..47e461594 100644
--- a/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
+++ b/apps/web/src/app/tournament/[id]/_common/_hooks/usePreloadMatchImages.ts
@@ -31,26 +31,23 @@ const usePreloadMatchImages = (imageUrls: (string | null | undefined)[]) => {
const image = new Image();
/** 현재 매치 이미지와 대역폭을 다투지 않도록 낮은 우선순위로 */
image.fetchPriority = 'low';
- /** srcset·sizes 를 src 보다 먼저 — 순서가 바뀌면 src 로 먼저 요청이 나간다 */
+
if (props.sizes) image.sizes = props.sizes;
if (props.srcSet) image.srcset = props.srcSet;
- const release = () => pendingImages.delete(image);
- image.onload = release;
- image.onerror = release;
+ image.onload = () => {
+ pendingImages.delete(image);
+ };
+ /** 실패한 URL은 제거해 다음 진입 시 재시도한다. */
+ image.onerror = () => {
+ pendingImages.delete(image);
+
+ preloadedUrlsRef.current.delete(imageUrl);
+ };
pendingImages.add(image);
image.src = props.src;
});
-
- return () => {
- /** 언마운트 시 보관 중인 이미지 참조와 이벤트 핸들러 정리 */
- pendingImages.forEach(image => {
- image.onload = null;
- image.onerror = null;
- });
- pendingImages.clear();
- };
}, [urlsKey]);
};