From d4e7f0da4085a3b6f184a45440bad7bfb08ac8f4 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 16:00:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=B0=EC=8A=B9=20=EC=A7=81=ED=9B=84?= =?UTF-8?q?=20=EB=A7=A4=EC=B9=98=20=EC=8A=A4=EC=BC=88=EB=A0=88=ED=86=A4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EA=B2=B0=EA=B3=BC=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0=ED=9A=8C=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 결과 페이지 RSC fetch 가 끝날 때까지 내비게이션이 커밋되지 않아 매치 화면에 대진 스켈레톤이 뜨던 문제를 해결한다. - result/layout.tsx 에 Suspense 경계 추가 — 내비게이션 즉시 커밋 - 데이터 대기를 page 의 async 자식으로 내려 fallback 이 걸리게 함 - ensureQueryData 로 상위 layout 이 심은 캐시 재사용 (중복 HTTP 제거) - 결승 기록 응답으로 캐시를 COMPLETED 시드 — 재조회 없이 즉시 렌더 --- .../[id]/match/_hooks/useTournament.ts | 39 ++++++++++++++++--- .../_components/ResultLoadingFallback.tsx | 13 +++++++ .../src/app/tournament/[id]/result/layout.tsx | 16 ++++++++ .../src/app/tournament/[id]/result/page.tsx | 28 ++++++++----- 4 files changed, 82 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/app/tournament/[id]/result/_components/ResultLoadingFallback.tsx create mode 100644 apps/web/src/app/tournament/[id]/result/layout.tsx diff --git a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts index eb7a9d50..24586641 100644 --- a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts +++ b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts @@ -13,6 +13,7 @@ import type { TournamentItemT } from '@/types/tournament'; import { getTournament } from '../../_common/_apis/getTournament'; import type { GetTournamentInProgressResponseT, + GetTournamentResponseT, TournamentMatchT, } from '../../_common/_types/tournamentResponse'; import { type TransitionStageT, getRoundLabel, getTransitionStage } from '../_consts/rounds'; @@ -33,11 +34,39 @@ const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const { postRecordMatchMutation, isPostRecordMatchPending } = usePostRecordMatch({ tournamentId, onSuccess: data => { - if (!data.completed) return; - // 결승 종료 후 result 페이지가 권위 응답(hasGroupResult/playLinkExpiresAt/isRoot 등) - // 을 받아야 하므로 클라 캐시는 비워 두고 SSR fresh data 로 채우게 한다. - // (시드해두면 stale 한 hasGroupResult=false 가 클라에 남아 카드 노출이 늦어짐) - queryClient.removeQueries({ queryKey: ['tournament', tournamentId] }); + const completed = data.completed; + if (!completed) return; + + // 결승 기록 응답에 결과가 이미 들어 있으므로 캐시를 비우지 않고 COMPLETED 로 시드한다. + // hasGroupResult 도 이 응답에 포함돼 stale 우려가 없다. + // 정체성 필드(name/isOwner/isRoot 등)는 응답에 없어 기존 캐시에서 가져온다. + const previous = queryClient.getQueryData([ + 'tournament', + tournamentId, + ]); + + // 캐시가 없으면 조합할 수 없다 — 결과 페이지의 SSR 응답에 맡긴다. + if (!previous) { + queryClient.removeQueries({ queryKey: ['tournament', tournamentId] }); + return; + } + + const { playLinkExpiresAt } = completed; + const { sourceTournamentId } = previous; + + queryClient.setQueryData(['tournament', tournamentId], { + tournamentId: previous.tournamentId, + name: previous.name, + isOwner: previous.isOwner, + isRoot: previous.isRoot, + ...(sourceTournamentId ? { sourceTournamentId } : {}), + status: 'COMPLETED', + completed: { + result: completed.result, + hasGroupResult: completed.hasGroupResult, + ...(playLinkExpiresAt ? { playLinkExpiresAt } : {}), + }, + }); }, }); diff --git a/apps/web/src/app/tournament/[id]/result/_components/ResultLoadingFallback.tsx b/apps/web/src/app/tournament/[id]/result/_components/ResultLoadingFallback.tsx new file mode 100644 index 00000000..0795b062 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_components/ResultLoadingFallback.tsx @@ -0,0 +1,13 @@ +/** + * 결과 화면 Suspense fallback. + * ResultClient 의 로딩 상태와 동일한 문구·배경을 써서 경계 전환이 드러나지 않게 한다. + */ +function ResultLoadingFallback() { + return ( +
+

결과를 불러오는 중...

+
+ ); +} + +export default ResultLoadingFallback; diff --git a/apps/web/src/app/tournament/[id]/result/layout.tsx b/apps/web/src/app/tournament/[id]/result/layout.tsx new file mode 100644 index 00000000..53038428 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/layout.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react'; + +import ResultLoadingFallback from './_components/ResultLoadingFallback'; + +/** + * Suspense 경계 전용 layout. + * + * 이 경계가 없으면 결과 페이지의 RSC fetch 가 끝날 때까지 내비게이션이 커밋되지 않아 + * 결승 직후 매치 화면에 머물며 대진 스켈레톤이 뜬다. + * + * loading.tsx 는 같은 레벨의 layout 을 감싸지 않으므로(`{page}`), + * 데이터를 기다리는 가드는 이 아래(page 의 async 자식)에 있어야 fallback 이 걸린다. + */ +export default function Layout({ children }: { children: React.ReactNode }) { + return }>{children}; +} diff --git a/apps/web/src/app/tournament/[id]/result/page.tsx b/apps/web/src/app/tournament/[id]/result/page.tsx index 38518006..ed1a0b7f 100644 --- a/apps/web/src/app/tournament/[id]/result/page.tsx +++ b/apps/web/src/app/tournament/[id]/result/page.tsx @@ -6,22 +6,26 @@ import { getIsGuest } from '@/utils/getIsGuest'; import { getQueryClient } from '@/utils/queryClient'; import { getTournament } from '../_common/_apis/getTournament'; -import type { GetTournamentResponseT } from '../_common/_types/tournamentResponse'; import ResultClient from './_components/ResultClient'; type TournamentResultPageProps = { params: Promise<{ id: string }>; }; -async function TournamentResultPage({ params }: TournamentResultPageProps) { - const { id } = await params; - const tournamentId = Number(id); - +/** + * 데이터를 기다리는 부분은 page 가 아닌 이 async 자식에 둔다. + * page 자체가 await 하면 layout 의 Suspense 경계 바깥에서 블로킹돼 + * 결승 직후 내비게이션이 커밋되지 않는다. + */ +async function ResultContent({ tournamentId }: { tournamentId: number }) { const queryClient = getQueryClient(); - // prefetchQuery 가 아닌 직접 fetch — 매치 결승 직후 시드(hasGroupResult=false 등)는 - // 결과 페이지에서는 권위 응답으로 반드시 덮어써야 한다. - const tournamentData = await getTournament(tournamentId); - queryClient.setQueryData(['tournament', tournamentId], tournamentData); + + // 상위 layout 이 이미 같은 요청 안에서 조회해 캐시에 심어둔다(서버 QueryClient 는 요청 단위 공유). + // ensureQueryData 로 그 값을 재사용해 결과 진입 시 중복 HTTP 요청을 없앤다. + const tournamentData = await queryClient.ensureQueryData({ + queryKey: ['tournament', tournamentId], + queryFn: () => getTournament(tournamentId), + }); if (tournamentData.status !== 'COMPLETED') { redirect(ROUTES.TOURNAMENT_MATCH(tournamentId)); @@ -36,4 +40,10 @@ async function TournamentResultPage({ params }: TournamentResultPageProps) { ); } +async function TournamentResultPage({ params }: TournamentResultPageProps) { + const { id } = await params; + + return ; +} + export default TournamentResultPage;