Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<GetTournamentInProgressResponseT>([
'tournament',
tournamentId,
]);

// 캐시가 없으면 조합할 수 없다 — 결과 페이지의 SSR 응답에 맡긴다.
if (!previous) {
queryClient.removeQueries({ queryKey: ['tournament', tournamentId] });
return;
}

const { playLinkExpiresAt } = completed;
const { sourceTournamentId } = previous;

queryClient.setQueryData<GetTournamentResponseT>(['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 } : {}),
},
});
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* 결과 화면 Suspense fallback.
* ResultClient 의 로딩 상태와 동일한 문구·배경을 써서 경계 전환이 드러나지 않게 한다.
*/
function ResultLoadingFallback() {
return (
<main className="flex min-h-dvh items-center justify-center bg-bg-layer-basement pt-padding-top">
<p className="body-1-medium text-text-neutral-tertiary">결과를 불러오는 중...</p>
</main>
);
}

export default ResultLoadingFallback;
16 changes: 16 additions & 0 deletions apps/web/src/app/tournament/[id]/result/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Suspense } from 'react';

import ResultLoadingFallback from './_components/ResultLoadingFallback';

/**
* Suspense 경계 전용 layout.
*
* 이 경계가 없으면 결과 페이지의 RSC fetch 가 끝날 때까지 내비게이션이 커밋되지 않아
* 결승 직후 매치 화면에 머물며 대진 스켈레톤이 뜬다.
*
* loading.tsx 는 같은 레벨의 layout 을 감싸지 않으므로(`<Layout><Suspense>{page}</Suspense></Layout>`),
* 데이터를 기다리는 가드는 이 아래(page 의 async 자식)에 있어야 fallback 이 걸린다.
*/
export default function Layout({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<ResultLoadingFallback />}>{children}</Suspense>;
}
28 changes: 19 additions & 9 deletions apps/web/src/app/tournament/[id]/result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetTournamentResponseT>(['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));
Expand All @@ -36,4 +40,10 @@ async function TournamentResultPage({ params }: TournamentResultPageProps) {
);
}

async function TournamentResultPage({ params }: TournamentResultPageProps) {
const { id } = await params;

return <ResultContent tournamentId={Number(id)} />;
}

export default TournamentResultPage;
Loading