From 070cf15b7e47d5794f86282d23740a415283875c Mon Sep 17 00:00:00 2001 From: jjangminii Date: Thu, 16 Jul 2026 04:32:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(web):=20=EC=88=98=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=94=8C=EB=A0=88=EC=9D=B4=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=86=A0=EA=B8=80=20=EC=95=88=20=EB=90=98=EB=8A=94?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay로 여는 모달의 onTogglePlay 콜백이 모달을 열 때의 낡은 activeTimer를 계속 참조하던 문제를 ref 기반 콜백으로 해결했습니다 - startTimer/changeStatus 진행 중이거나 activeTimer 리페치 중에는 재생 버튼 클릭을 무시하도록 가드를 추가했습니다 --- .../home/_hooks/use-home-todos-by-date.ts | 25 +++++++++++++------ .../today/_hooks/useTodayTodoList.ts | 20 +++++++++++---- .../detail/DetailTodoModalContainer.tsx | 11 ++++++-- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index fff1170b..a9562e3c 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -43,7 +43,8 @@ export const useHomeTodosByDate = ( const [todosByDate, setTodosByDate] = useState>({}); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); const queryClient = useQueryClient(); - const { data: activeTimer } = useActiveTimer(); + const { data: activeTimer, isFetching: isActiveTimerFetching } = + useActiveTimer(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: reorderTodo } = useReorderTodo(); @@ -51,12 +52,19 @@ export const useHomeTodosByDate = ( const { invalidateHomeView, invalidateTimerState, invalidateTimeBoxes } = useTimerQueryInvalidation(); - const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, - }); - const { mutate: changeStatus } = useChangeStatus({ - mutation: { onSuccess: invalidateTimerState }, - }); + const { mutate: startTimer, isPending: isStartTimerPending } = + useStartTimer({ + mutation: { onSuccess: invalidateTimerState }, + }); + const { mutate: changeStatus, isPending: isChangeStatusPending } = + useChangeStatus({ + mutation: { onSuccess: invalidateTimerState }, + }); + + // startTimer/changeStatus 응답 후 activeTimer가 최신화되기 전에 재클릭하면 + // 낡은(stale) activeTimer 값으로 잘못된 분기(중복 시작 등)를 타므로, 진행 중에는 재생 버튼 입력을 무시한다 + const isTimerActionPending = + isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; useEffect(() => { setTodosByDate( @@ -147,6 +155,8 @@ export const useHomeTodosByDate = ( }; const handleTogglePlay = (dateKey: string, todoId: number) => { + if (isTimerActionPending) return; + const willRun = todosByDate[dateKey]?.find((todo) => todo.todoId === todoId) ?.timerStatus !== "RUNNING"; @@ -250,6 +260,7 @@ export const useHomeTodosByDate = ( return { todosByDate, activeTimer, + isTimerActionPending, handleToggleCompleted, handleTogglePlay, handleToggleSubtaskCompleted, diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts index b4b49af2..47efc68e 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts @@ -41,19 +41,26 @@ export const useTodayTodoList = ( const [todos, setTodos] = useState(initialTodos); const openTimerPanel = useTimeSidebarStore((state) => state.openTimerPanel); const queryClient = useQueryClient(); - const { data: activeTimer } = useActiveTimer(); + const { data: activeTimer, isFetching: isActiveTimerFetching } = + useActiveTimer(); const { mutate: changeTodoStatus } = useChangeTodoStatus(); const { mutate: changeSubtaskStatus } = useChangeSubtaskStatus(); const { mutate: stopTimer } = useStopTimer(); const { invalidateTimerState, invalidateTimeBoxes } = useTimerQueryInvalidation(); - const { mutate: startTimer } = useStartTimer({ - mutation: { onSuccess: invalidateTimerState }, - }); - const { mutate: changeStatus } = useChangeStatus({ + const { mutate: startTimer, isPending: isStartTimerPending } = useStartTimer({ mutation: { onSuccess: invalidateTimerState }, }); + const { mutate: changeStatus, isPending: isChangeStatusPending } = + useChangeStatus({ + mutation: { onSuccess: invalidateTimerState }, + }); + + // startTimer/changeStatus 응답 후 activeTimer가 최신화되기 전에 재클릭하면 + // 낡은(stale) activeTimer 값으로 잘못된 분기(중복 시작 등)를 타므로, 진행 중에는 재생 버튼 입력을 무시한다 + const isTimerActionPending = + isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; useEffect(() => { setTodos(initialTodos); @@ -133,6 +140,8 @@ export const useTodayTodoList = ( }; const handlePlay = (todoId: number) => { + if (isTimerActionPending) return; + const dateKey = todos.find((todo) => todo.todoId === todoId)?.date; if (!dateKey) return; @@ -218,6 +227,7 @@ export const useTodayTodoList = ( return { todos, activeTimer, + isTimerActionPending, handlePlay, handleToggleCompleted, handleDelete, diff --git a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx index c80dbb85..8a2c50c1 100644 --- a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx +++ b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx @@ -3,7 +3,7 @@ import { keepPreviousData } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { overlay } from "overlay-kit"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { ErrorType } from "@/api/client/custom-instance"; import type { ErrorDto, TodoUpdateRequest } from "@/api/generated/models"; @@ -153,6 +153,13 @@ export const DetailTodoModalContainer = ({ const [actionErrorMessage, setActionErrorMessage] = useState(""); const [isActionErrorToastOpen, setIsActionErrorToastOpen] = useState(false); + // overlay.open()에 넘긴 콜백은 모달을 여는 순간에 한 번만 호출되고 이후 다시 호출되지 + // 않으므로, onTogglePlay를 그대로 캡처하면 모달이 열려있는 동안 activeTimer가 바뀌어도 + // 그 시점의 낡은 activeTimer를 참조하는 콜백이 계속 쓰인다. ref로 감싸 클릭 시점에 + // 항상 최신 콜백을 참조하도록 한다. + const onTogglePlayRef = useRef(onTogglePlay); + onTogglePlayRef.current = onTogglePlay; + const showActionErrorToast = useCallback( (error: ErrorType) => { setActionErrorMessage( @@ -171,7 +178,7 @@ export const DetailTodoModalContainer = ({ isOpen={isOpen} onClose={close} onExited={unmount} - onTogglePlay={onTogglePlay} + onTogglePlay={() => onTogglePlayRef.current()} onToggleCompleted={onToggleCompleted} onDelete={onDelete} onActionError={showActionErrorToast} From 57901b567b44389597bf5c4df6a6ec6f3c61d848 Mon Sep 17 00:00:00 2001 From: jjangminii Date: Thu, 16 Jul 2026 04:39:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?comment(web):=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드만으로 파악되는 설명 주석을 정리했습니다 --- .../(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts | 2 -- .../(with-time-sidebar)/today/_hooks/useTodayTodoList.ts | 2 -- .../containers/todo-modal/detail/DetailTodoModalContainer.tsx | 4 ---- 3 files changed, 8 deletions(-) diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts index a9562e3c..a8848416 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/home/_hooks/use-home-todos-by-date.ts @@ -61,8 +61,6 @@ export const useHomeTodosByDate = ( mutation: { onSuccess: invalidateTimerState }, }); - // startTimer/changeStatus 응답 후 activeTimer가 최신화되기 전에 재클릭하면 - // 낡은(stale) activeTimer 값으로 잘못된 분기(중복 시작 등)를 타므로, 진행 중에는 재생 버튼 입력을 무시한다 const isTimerActionPending = isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; diff --git a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts index 47efc68e..ec87f47b 100644 --- a/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts +++ b/apps/timo-web/app/[locale]/(main)/(with-time-sidebar)/today/_hooks/useTodayTodoList.ts @@ -57,8 +57,6 @@ export const useTodayTodoList = ( mutation: { onSuccess: invalidateTimerState }, }); - // startTimer/changeStatus 응답 후 activeTimer가 최신화되기 전에 재클릭하면 - // 낡은(stale) activeTimer 값으로 잘못된 분기(중복 시작 등)를 타므로, 진행 중에는 재생 버튼 입력을 무시한다 const isTimerActionPending = isStartTimerPending || isChangeStatusPending || isActiveTimerFetching; diff --git a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx index 8a2c50c1..456de63c 100644 --- a/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx +++ b/apps/timo-web/containers/todo-modal/detail/DetailTodoModalContainer.tsx @@ -153,10 +153,6 @@ export const DetailTodoModalContainer = ({ const [actionErrorMessage, setActionErrorMessage] = useState(""); const [isActionErrorToastOpen, setIsActionErrorToastOpen] = useState(false); - // overlay.open()에 넘긴 콜백은 모달을 여는 순간에 한 번만 호출되고 이후 다시 호출되지 - // 않으므로, onTogglePlay를 그대로 캡처하면 모달이 열려있는 동안 activeTimer가 바뀌어도 - // 그 시점의 낡은 activeTimer를 참조하는 콜백이 계속 쓰인다. ref로 감싸 클릭 시점에 - // 항상 최신 콜백을 참조하도록 한다. const onTogglePlayRef = useRef(onTogglePlay); onTogglePlayRef.current = onTogglePlay;