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
26 changes: 23 additions & 3 deletions apps/web/app/mission/_components/mission-loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,40 @@ import { Button } from "@repo/ui";
import MissionLoadingCoin from "@repo/ui/svg/mission-loading-coin.svg";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { buildMissionCreationResultHref } from "@/app/mission/constants/mission-creation";
import { generationJobStatusOptions } from "@/lib/queries/mission-generation";
import styles from "./mission-loading.module.css";

const MIN_LOADING_DURATION_MS = 8_000;
const MAX_LOADING_DURATION_MS = 12_000;

function getLoadingDuration() {
return (
MIN_LOADING_DURATION_MS +
Math.floor(Math.random() * (MAX_LOADING_DURATION_MS - MIN_LOADING_DURATION_MS + 1))
);
}

/**
* AI 미션 초안 생성 job이 끝날 때까지 polling한다. jobId는 설문 제출 단계에서 만들어
* URL로 넘겨받는다 — 생성 화면에서 mutation을 쏘지 않으므로 새로고침해도 새 job이 생기지 않고,
* StrictMode에서 mutation 결과가 유실되던 문제도 없다. 반복 폴링은 refetchInterval에 맡긴다.
*/
export function MissionLoading({ jobId }: { jobId: string }) {
const router = useRouter();
const [loadingDuration] = useState(getLoadingDuration);
const [hasMinimumLoadingTimeElapsed, setHasMinimumLoadingTimeElapsed] = useState(false);
const { data: job, isError, refetch } = useQuery(generationJobStatusOptions(jobId));

useEffect(() => {
const timeoutId = window.setTimeout(
() => setHasMinimumLoadingTimeElapsed(true),
loadingDuration,
);
return () => window.clearTimeout(timeoutId);
}, [loadingDuration]);

useEffect(() => {
const refetchOnAppActive = () => {
void refetch();
Expand All @@ -27,10 +47,10 @@ export function MissionLoading({ jobId }: { jobId: string }) {
}, [refetch]);

useEffect(() => {
if (job?.status === "SUCCEEDED" && job.draftsAvailable) {
if (hasMinimumLoadingTimeElapsed && job?.status === "SUCCEEDED" && job.draftsAvailable) {
router.replace(buildMissionCreationResultHref(jobId));
}
}, [job, jobId, router]);
}, [hasMinimumLoadingTimeElapsed, job, jobId, router]);

// 5초마다 폴링하므로 일시적인 조회 실패로 "생성 실패"를 띄우면 안 된다 — 다음 폴링이
// 성공할 수 있다. 서버가 FAILED를 주거나, 첫 조회부터 실패해 상태를 아예 못 받은 경우만 실패다.
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/mission/new/loading/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ const PENDING_JOB = {
describe("MissionLoading", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(Math, "random").mockReturnValue(0);
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
});

Expand Down Expand Up @@ -114,6 +116,11 @@ describe("MissionLoading", () => {

renderWithClient();

await vi.waitFor(() => expect(fetchGenerationJobStatus).toHaveBeenCalledTimes(1));
expect(replaceMock).not.toHaveBeenCalled();

await vi.advanceTimersByTimeAsync(8_000);

await vi.waitFor(() =>
expect(replaceMock).toHaveBeenCalledWith("/mission/new/result?jobId=job-1"),
);
Expand Down
Loading