diff --git a/src/api/errorHandler.test.ts b/src/api/errorHandler.test.ts index a65a346c..0f8bf1a8 100644 --- a/src/api/errorHandler.test.ts +++ b/src/api/errorHandler.test.ts @@ -30,7 +30,7 @@ describe('handleApiError', () => { }); describe('401 handling', () => { - it('calls logout and shows toast when logged in', () => { + it('does not logout and falls back to generic toast when logged in', () => { // Set logged in state useAuthStore .getState() @@ -38,16 +38,13 @@ describe('handleApiError', () => { handleApiError(401, ''); - expect(useAuthStore.getState().accessToken).toBeNull(); - expect(showToast.error).toHaveBeenCalledWith( - '로그인이 만료되었습니다.', - '다시 로그인해주세요.', - ); + expect(useAuthStore.getState().accessToken).toBe('some-token'); + expect(showToast.error).toHaveBeenCalledWith('요청을 처리하지 못했습니다.', ''); }); - it('does nothing when not logged in', () => { + it('falls back to generic toast when not logged in', () => { handleApiError(401, ''); - expect(showToast.error).not.toHaveBeenCalled(); + expect(showToast.error).toHaveBeenCalledWith('요청을 처리하지 못했습니다.', ''); }); }); diff --git a/src/api/queryClient.test.ts b/src/api/queryClient.test.ts index 642810eb..821a1ec9 100644 --- a/src/api/queryClient.test.ts +++ b/src/api/queryClient.test.ts @@ -60,6 +60,22 @@ describe('queryKeys', () => { }); }); + describe('videos', () => { + it('builds list prefix key with projectId', () => { + expect(queryKeys.videos.listPrefix('p1')).toEqual(['videos', 'list', 'p1']); + }); + + it('builds list key with params', () => { + expect( + queryKeys.videos.list('p1', { search: 'abc', filter: 'ready', sort: 'recent' }), + ).toEqual(['videos', 'list', 'p1', { search: 'abc', filter: 'ready', sort: 'recent' }]); + }); + + it('builds list key without params', () => { + expect(queryKeys.videos.list('p1')).toEqual(['videos', 'list', 'p1', {}]); + }); + }); + describe('shares', () => { it('builds content key with sessionId', () => { expect(queryKeys.shares.content('token1', 'sess1')).toEqual([ diff --git a/src/api/queryClient.ts b/src/api/queryClient.ts index 7b4d6a1f..5ee97187 100644 --- a/src/api/queryClient.ts +++ b/src/api/queryClient.ts @@ -69,7 +69,8 @@ export const queryKeys = { all: ['scripts'] as const, detail: (slideId: string) => [...queryKeys.scripts.all, 'detail', slideId] as const, versions: (slideId: string) => [...queryKeys.scripts.all, 'versions', slideId] as const, - project: (projectId: string) => [...queryKeys.scripts.all, 'project', projectId] as const, + projects: () => [...queryKeys.scripts.all, 'project'] as const, + project: (projectId: string) => [...queryKeys.scripts.projects(), projectId] as const, }, presentations: { all: ['presentations'] as const, @@ -87,7 +88,15 @@ export const queryKeys = { videos: { all: ['videos'] as const, lists: () => [...queryKeys.videos.all, 'list'] as const, - list: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const, + listPrefix: (projectId: string) => [...queryKeys.videos.lists(), projectId] as const, + list: ( + projectId: string, + params?: { + search?: string; + filter?: string; + sort?: string; + }, + ) => [...queryKeys.videos.listPrefix(projectId), params ?? {}] as const, details: () => [...queryKeys.videos.all, 'detail'] as const, detail: (videoId: string) => [...queryKeys.videos.details(), videoId] as const, slides: (videoId: number) => [...queryKeys.videos.all, 'slides', videoId] as const, diff --git a/src/components/common/layout/Gnb.tsx b/src/components/common/layout/Gnb.tsx index 2c272241..941ab8cc 100644 --- a/src/components/common/layout/Gnb.tsx +++ b/src/components/common/layout/Gnb.tsx @@ -10,6 +10,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'; import clsx from 'clsx'; import { TABS, getTabFromPathname, getTabPath } from '@/constants/navigation'; +import { useProjectEntryPrefetch } from '@/hooks/queries/useProjectEntryPrefetch'; export function Gnb() { const { projectId } = useParams<{ projectId: string }>(); @@ -18,6 +19,8 @@ export function Gnb() { const activeIndex = TABS.findIndex((tab) => tab.key === activeTab); const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0; + useProjectEntryPrefetch(projectId); + if (!projectId) return null; return ( diff --git a/src/components/slide/SlideWorkspace.tsx b/src/components/slide/SlideWorkspace.tsx index 1eb75165..fcfa558a 100644 --- a/src/components/slide/SlideWorkspace.tsx +++ b/src/components/slide/SlideWorkspace.tsx @@ -7,11 +7,11 @@ * - ScriptBox 접힘 상태를 관리하고 SlideViewer에 전달 * - Zustand store로 슬라이드 상태 관리 */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { SLIDE_MAX_WIDTH } from '@/constants/layout'; import { useSlideActions, useSlideId, useSlideScript } from '@/hooks'; -import { useScript } from '@/hooks/queries/useScript'; +import { useProjectScripts, useScript } from '@/hooks/queries/useScript'; import { useSlideCommentsLoader } from '@/hooks/useSlideCommentsLoader'; import type { SlideListItem } from '@/types/slide'; @@ -28,24 +28,42 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps const { initSlide, updateScript, updateSlide } = useSlideActions(); const slideId = useSlideId(); const script = useSlideScript(); - const { data: scriptData } = useScript(slideId); const lastSyncedSlideIdRef = useRef(''); const lastSyncedScriptRef = useRef(null); + const projectId = slide?.projectId ?? ''; + const currentSlideId = slide?.slideId ?? ''; + const { data: projectScripts } = useProjectScripts(projectId, { + enabled: !!projectId, + staleTime: 1000 * 60 * 10, + }); + + const projectScript = useMemo(() => { + if (!currentSlideId) return undefined; + return projectScripts?.scripts.find((item) => item.slideId === currentSlideId)?.scriptText; + }, [projectScripts, currentSlideId]); + + const shouldFetchDetailScript = !projectScript && !slide?.script; + const { data: scriptData } = useScript(currentSlideId, { + enabled: shouldFetchDetailScript, + staleTime: 1000 * 60 * 10, + }); + + const resolvedServerScript = projectScript ?? slide?.script ?? scriptData?.scriptText ?? ''; + useEffect(() => { if (!slide) return; + const nextSlide = + resolvedServerScript !== slide.script ? { ...slide, script: resolvedServerScript } : slide; const isSameSlide = slide.slideId === slideId; if (isSameSlide) { - updateSlide(slide); + updateSlide(nextSlide); return; } - initSlide(slide); - updateScript(''); - }, [slide, slideId, initSlide, updateScript, updateSlide]); - - useSlideCommentsLoader(slide?.slideId); + initSlide(nextSlide); + }, [slide, slideId, initSlide, resolvedServerScript, updateSlide]); useEffect(() => { if (lastSyncedSlideIdRef.current !== slideId) { @@ -55,13 +73,13 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps }, [slideId]); useEffect(() => { - if (!scriptData) return; + if (!slideId) return; - const serverScript = scriptData.scriptText; + const serverScript = resolvedServerScript; const hasSyncedOnce = lastSyncedScriptRef.current !== null; const hasLocalEditAfterSync = hasSyncedOnce && script !== lastSyncedScriptRef.current; - // 로컬 편집이 있는 동안에는 서버 응답으로 덮어쓰지 않습니다. + // 로컬 편집 중에는 서버 값으로 덮어쓰지 않습니다. if (hasLocalEditAfterSync && script !== serverScript) return; if (script !== serverScript) { @@ -69,7 +87,9 @@ export default function SlideWorkspace({ slide, isLoading }: SlideWorkspaceProps } lastSyncedScriptRef.current = serverScript; - }, [script, scriptData, updateScript]); + }, [resolvedServerScript, script, slideId, updateScript]); + + useSlideCommentsLoader(slide?.slideId); return (
diff --git a/src/components/slide/script/ScriptBoxContent.tsx b/src/components/slide/script/ScriptBoxContent.tsx index 9577e94d..52a735e4 100644 --- a/src/components/slide/script/ScriptBoxContent.tsx +++ b/src/components/slide/script/ScriptBoxContent.tsx @@ -40,15 +40,15 @@ export default function ScriptBoxContent() { onBlur={flushSave} placeholder="슬라이드 대본을 입력하세요..." aria-label="슬라이드 대본" - className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 pr-28 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600" + className="h-full w-full resize-none overflow-y-auto border-none bg-transparent pb-8 text-base leading-relaxed text-gray-800 outline-none placeholder:text-gray-600" /> -
+
diff --git a/src/hooks/UseSlideSelectors.test.tsx b/src/hooks/UseSlideSelectors.test.tsx index 55fef620..b32a9010 100644 --- a/src/hooks/UseSlideSelectors.test.tsx +++ b/src/hooks/UseSlideSelectors.test.tsx @@ -8,6 +8,7 @@ import { useSlideActions, useSlideComments, useSlideId, + useSlideProjectId, useSlideScript, useSlideThumb, useSlideTitle, @@ -31,6 +32,11 @@ describe('useSlideSelectors', () => { expect(result.current).toBe(''); }); + it('useSlideProjectId returns empty string', () => { + const { result } = renderHook(() => useSlideProjectId()); + expect(result.current).toBe(''); + }); + it('useSlideThumb returns empty string', () => { const { result } = renderHook(() => useSlideThumb()); expect(result.current).toBe(''); @@ -70,6 +76,11 @@ describe('useSlideSelectors', () => { expect(result.current).toBe('Test Title'); }); + it('useSlideProjectId returns projectId', () => { + const { result } = renderHook(() => useSlideProjectId()); + expect(result.current).toBe(slide.projectId); + }); + it('useSlideThumb returns imageUrl', () => { const { result } = renderHook(() => useSlideThumb()); expect(result.current).toBe('https://img.url'); diff --git a/src/hooks/queries/useProjectEntryPrefetch.ts b/src/hooks/queries/useProjectEntryPrefetch.ts new file mode 100644 index 00000000..f8a28e52 --- /dev/null +++ b/src/hooks/queries/useProjectEntryPrefetch.ts @@ -0,0 +1,36 @@ +import { useEffect, useRef } from 'react'; + +import { useQueryClient } from '@tanstack/react-query'; + +import { getProjectScripts } from '@/api/endpoints/scripts'; +import { getSlides } from '@/api/endpoints/slides'; +import { queryKeys } from '@/api/queryClient'; + +/** + * 프로젝트 진입 시 슬라이드/대본 데이터를 한 번 미리 적재합니다. + */ +export function useProjectEntryPrefetch(projectId?: string) { + const queryClient = useQueryClient(); + const prefetchedProjectIdsRef = useRef>(new Set()); + + useEffect(() => { + if (!projectId) return; + if (prefetchedProjectIdsRef.current.has(projectId)) return; + + prefetchedProjectIdsRef.current.add(projectId); + + void Promise.all([ + queryClient.prefetchQuery({ + queryKey: queryKeys.slides.list(projectId), + queryFn: () => getSlides(projectId), + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.scripts.project(projectId), + queryFn: () => getProjectScripts(projectId), + }), + ]).catch(() => { + // 실패 시 재시도 가능하도록 가드를 해제합니다. + prefetchedProjectIdsRef.current.delete(projectId); + }); + }, [projectId, queryClient]); +} diff --git a/src/hooks/queries/useScript.ts b/src/hooks/queries/useScript.ts index 43edeaa9..2ee6d4e9 100644 --- a/src/hooks/queries/useScript.ts +++ b/src/hooks/queries/useScript.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { BulkEditScriptsRequestDto, + GetProjectScriptsResponseDto, RestoreScriptRequestDto, UpdateScriptRequestDto, } from '@/api/dto'; @@ -14,17 +15,38 @@ import { updateScript, } from '@/api/endpoints/scripts'; import { queryKeys } from '@/api/queryClient'; +import type { SlideListItem } from '@/types/slide'; + +interface UpdateScriptMutationVariables { + slideId: string; + projectId?: string; + data: UpdateScriptRequestDto; +} + +function getProjectIdFromListQueryKey(queryKey: readonly unknown[]): string | null { + const projectId = queryKey[2]; + return typeof projectId === 'string' ? projectId : null; +} /** * 대본 조회 * * @param slideId - 슬라이드 ID */ -export function useScript(slideId: string) { +export function useScript( + slideId: string, + options?: { + enabled?: boolean; + staleTime?: number; + }, +) { + const isEnabled = options?.enabled ?? true; + return useQuery({ queryKey: queryKeys.scripts.detail(slideId), queryFn: () => getScript(slideId), - enabled: !!slideId, + enabled: !!slideId && isEnabled, + staleTime: options?.staleTime, }); } @@ -35,15 +57,115 @@ export function useUpdateScript() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ slideId, data }: { slideId: string; data: UpdateScriptRequestDto }) => - updateScript(slideId, data), + mutationFn: ({ slideId, data }: UpdateScriptMutationVariables) => updateScript(slideId, data), onMutate: async ({ slideId }) => { await queryClient.cancelQueries({ queryKey: queryKeys.scripts.detail(slideId) }); }, - onSuccess: (savedScript, { slideId }) => { + onSuccess: (savedScript, { slideId, projectId }) => { queryClient.setQueryData(queryKeys.scripts.detail(slideId), savedScript); + + const matchedProjectIds = new Set(); + + const updateSlideListCache = (targetProjectId: string) => { + queryClient.setQueryData( + queryKeys.slides.list(targetProjectId), + (slides) => { + if (!slides) return slides; + + let hasUpdated = false; + const nextSlides = slides.map((slide) => { + if (slide.slideId !== slideId) return slide; + hasUpdated = true; + return { ...slide, script: savedScript.scriptText }; + }); + + if (hasUpdated) { + matchedProjectIds.add(targetProjectId); + return nextSlides; + } + + return slides; + }, + ); + }; + + if (projectId) { + updateSlideListCache(projectId); + } else { + queryClient + .getQueriesData({ queryKey: queryKeys.slides.lists() }) + .forEach(([queryKey, slides]) => { + if (!slides) return; + + const keyProjectId = getProjectIdFromListQueryKey(queryKey); + if (!keyProjectId) return; + + let hasUpdated = false; + const nextSlides = slides.map((slide) => { + if (slide.slideId !== slideId) return slide; + hasUpdated = true; + return { ...slide, script: savedScript.scriptText }; + }); + + if (!hasUpdated) return; + + matchedProjectIds.add(keyProjectId); + queryClient.setQueryData(queryKey, nextSlides); + }); + } + + if (projectId) { + matchedProjectIds.add(projectId); + } + + if (matchedProjectIds.size > 0) { + matchedProjectIds.forEach((matchedProjectId) => { + queryClient.setQueryData( + queryKeys.scripts.project(matchedProjectId), + (projectScripts) => { + if (!projectScripts) return projectScripts; + + let hasUpdated = false; + const nextScripts = projectScripts.scripts.map((scriptItem) => { + if (scriptItem.slideId !== slideId) return scriptItem; + hasUpdated = true; + return { ...scriptItem, scriptText: savedScript.scriptText }; + }); + + if (!hasUpdated) return projectScripts; + + return { + ...projectScripts, + scripts: nextScripts, + }; + }, + ); + }); + } else { + // 슬라이드 목록 캐시가 비어있는 경우를 대비해 프로젝트 스크립트 캐시를 직접 탐색합니다. + queryClient + .getQueriesData({ queryKey: queryKeys.scripts.projects() }) + .forEach(([queryKey, projectScripts]) => { + if (!projectScripts) return; + + let hasUpdated = false; + const nextScripts = projectScripts.scripts.map((scriptItem) => { + if (scriptItem.slideId !== slideId) return scriptItem; + hasUpdated = true; + return { ...scriptItem, scriptText: savedScript.scriptText }; + }); + + if (!hasUpdated) return; + + queryClient.setQueryData(queryKey, { + ...projectScripts, + scripts: nextScripts, + }); + }); + } + void queryClient.invalidateQueries({ queryKey: queryKeys.scripts.versions(slideId) }); }, }); @@ -82,11 +204,20 @@ export function useRestoreScript() { /** * 프로젝트 전체 대본 조회 */ -export function useProjectScripts(projectId: string) { +export function useProjectScripts( + projectId: string, + options?: { + enabled?: boolean; + staleTime?: number; + }, +) { + const isEnabled = options?.enabled ?? true; + return useQuery({ queryKey: queryKeys.scripts.project(projectId), queryFn: () => getProjectScripts(projectId), - enabled: !!projectId, + enabled: !!projectId && isEnabled, + staleTime: options?.staleTime, }); } diff --git a/src/hooks/queries/useSharedComments.ts b/src/hooks/queries/useSharedComments.ts index c78d81c5..d030ea4e 100644 --- a/src/hooks/queries/useSharedComments.ts +++ b/src/hooks/queries/useSharedComments.ts @@ -6,6 +6,8 @@ import type { ReadSharedCommentsData } from '@/types/share'; type UseSharedCommentsOptions = { initialData?: ReadSharedCommentsData; + enabled?: boolean; + staleTime?: number; }; export function useSharedComments( @@ -13,10 +15,13 @@ export function useSharedComments( sessionId?: string, options: UseSharedCommentsOptions = {}, ) { + const isEnabled = options.enabled ?? true; + return useQuery({ queryKey: queryKeys.shares.comments(shareToken, sessionId), queryFn: () => getSharedComments(shareToken, sessionId), - enabled: !!shareToken, + enabled: !!shareToken && isEnabled, initialData: options.initialData, + staleTime: options.staleTime, }); } diff --git a/src/hooks/queries/useSlides.test.tsx b/src/hooks/queries/useSlides.test.tsx new file mode 100644 index 00000000..d8ef59cc --- /dev/null +++ b/src/hooks/queries/useSlides.test.tsx @@ -0,0 +1,76 @@ +import { useQuery } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSlides } from './useSlides'; + +vi.mock('@tanstack/react-query', async () => { + const actual = + await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +describe('useSlides', () => { + const mockedUseQuery = vi.mocked(useQuery); + + beforeEach(() => { + vi.clearAllMocks(); + mockedUseQuery.mockReturnValue({} as never); + }); + + it('기본값은 폴링이 비활성화된다', () => { + renderHook(() => useSlides('project-1')); + + const options = mockedUseQuery.mock.calls[0]?.[0] as { + enabled: boolean; + refetchInterval: number | false; + }; + + expect(options.enabled).toBe(true); + expect(options.refetchInterval).toBe(false); + }); + + it('liveSync가 true면 지정한 주기로 폴링한다', () => { + renderHook(() => useSlides('project-1', { liveSync: true, pollingIntervalMs: 15000 })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as unknown as { + refetchInterval: (query: { state: { error: unknown } }) => number | false; + }; + + const getInterval = options.refetchInterval; + expect(getInterval({ state: { error: null } })).toBe(15000); + }); + + it('liveSync 폴링 중 401 에러면 폴링을 중단한다', () => { + renderHook(() => useSlides('project-1', { liveSync: true })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as unknown as { + refetchInterval: (query: { state: { error: unknown } }) => number | false; + }; + + const getInterval = options.refetchInterval; + expect( + getInterval({ + state: { + error: { + isAxiosError: true, + response: { status: 401 }, + }, + }, + }), + ).toBe(false); + }); + + it('enabled 옵션으로 요청을 비활성화할 수 있다', () => { + renderHook(() => useSlides('project-1', { enabled: false })); + + const options = mockedUseQuery.mock.calls[0]?.[0] as { + enabled: boolean; + }; + + expect(options.enabled).toBe(false); + }); +}); diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 6af1a030..f39c8892 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -8,27 +8,40 @@ import type { UpdateSlideTitleRequestDto } from '@/api/dto'; import { getSlides, updateSlide } from '@/api/endpoints/slides'; import { queryKeys } from '@/api/queryClient'; +type UseSlidesOptions = { + enabled?: boolean; + liveSync?: boolean; + pollingIntervalMs?: number; +}; + /** * 슬라이드 목록 조회 * * @param projectId - 프로젝트 ID + * @param options - 조회 옵션 + * @param options.enabled - 쿼리 활성화 여부 (기본값: true) + * @param options.liveSync - 폴링 기반 라이브 동기화 여부 (기본값: false) + * @param options.pollingIntervalMs - 라이브 동기화 폴링 간격(ms) (기본값: 15000) */ -export function useSlides(projectId: string) { +export function useSlides( + projectId: string, + { enabled: isEnabled = true, liveSync = false, pollingIntervalMs = 15000 }: UseSlidesOptions = {}, +) { return useQuery({ queryKey: queryKeys.slides.list(projectId), queryFn: () => getSlides(projectId), - enabled: !!projectId, + enabled: !!projectId && isEnabled, retry: false, - // 🔄 서버가 웹소켓 브로드캐스트를 안하므로 임시로 폴링 추가 - // TODO: 서버에서 broadcastNewComment 호출 후 제거 - refetchInterval: (query) => { - const error = query.state.error; - if (isAxiosError(error) && error.response?.status === 401) { - return false; - } - return 3000; - }, // 3초마다 자동 갱신 (401이면 중단) - refetchIntervalInBackground: false, // 탭이 백그라운드일 때는 멈춤 + refetchInterval: liveSync + ? (query) => { + const error = query.state.error; + if (isAxiosError(error) && error.response?.status === 401) { + return false; + } + return pollingIntervalMs; + } + : false, + refetchIntervalInBackground: false, }); } diff --git a/src/hooks/useAutoSaveScript.test.ts b/src/hooks/useAutoSaveScript.test.ts index 03668c54..4e70550b 100644 --- a/src/hooks/useAutoSaveScript.test.ts +++ b/src/hooks/useAutoSaveScript.test.ts @@ -39,7 +39,9 @@ describe('useAutoSaveScript', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); - useSlideStore.getState().initSlide(createMockSlide({ slideId: 'slide-1' })); + useSlideStore + .getState() + .initSlide(createMockSlide({ slideId: 'slide-1', projectId: 'project-1' })); }); afterEach(() => { @@ -63,6 +65,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledWith({ slideId: 'slide-1', + projectId: 'project-1', data: { script: 'new script' }, }); }); @@ -156,6 +159,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledTimes(2); expect(mockMutateAsync).toHaveBeenLastCalledWith({ slideId: 'slide-1', + projectId: 'project-1', data: { script: 'offline draft' }, }); }); @@ -177,6 +181,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledTimes(1); expect(mockMutateAsync).toHaveBeenCalledWith({ slideId: 'slide-1', + projectId: 'project-1', data: { script: 'quick draft' }, }); @@ -203,6 +208,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledTimes(1); expect(mockMutateAsync).toHaveBeenCalledWith({ slideId: 'slide-1', + projectId: 'project-1', data: { script: 'blur draft' }, }); }); @@ -223,6 +229,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledTimes(1); expect(mockMutateAsync).toHaveBeenNthCalledWith(1, { slideId: 'slide-1', + projectId: 'project-1', data: { script: 'first' }, }); @@ -243,6 +250,7 @@ describe('useAutoSaveScript', () => { expect(mockMutateAsync).toHaveBeenCalledTimes(2); expect(mockMutateAsync).toHaveBeenNthCalledWith(2, { slideId: 'slide-1', + projectId: 'project-1', data: { script: 'second' }, }); }); diff --git a/src/hooks/useAutoSaveScript.ts b/src/hooks/useAutoSaveScript.ts index 54966dfc..220f0609 100644 --- a/src/hooks/useAutoSaveScript.ts +++ b/src/hooks/useAutoSaveScript.ts @@ -4,7 +4,7 @@ import { useUpdateScript } from '@/hooks/queries/useScript'; import { showToast } from '@/utils/toast'; import { useDebouncedCallback } from './useDebounce'; -import { useSlideId } from './useSlideSelectors'; +import { useSlideId, useSlideProjectId } from './useSlideSelectors'; const AUTOSAVE_DELAY = 300; @@ -23,6 +23,7 @@ const AUTOSAVE_DELAY = 300; */ export function useAutoSaveScript() { const slideId = useSlideId(); + const projectId = useSlideProjectId(); const { mutateAsync, isPending } = useUpdateScript(); const lastSavedRef = useRef(''); const pendingScriptRef = useRef(null); @@ -40,7 +41,7 @@ export function useAutoSaveScript() { isSavingRef.current = true; let saveSucceeded = false; try { - await mutateAsync({ slideId, data: { script: scriptToSave } }); + await mutateAsync({ slideId, projectId, data: { script: scriptToSave } }); if (activeSlideIdRef.current !== slideId) return; @@ -61,7 +62,7 @@ export function useAutoSaveScript() { if (saveSucceeded && nextPending !== null && nextPending !== lastSavedRef.current) { void flushPendingSave(); } - }, [slideId, mutateAsync]); + }, [slideId, projectId, mutateAsync]); const scheduleFlush = useDebouncedCallback(() => { void flushPendingSave(); diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts index 2476a8d8..dbcd88f7 100644 --- a/src/hooks/useInsightPageModel.ts +++ b/src/hooks/useInsightPageModel.ts @@ -42,7 +42,7 @@ export function useInsightPageModel(): InsightModel { const projectIdStr = projectId ?? ''; const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; - const slidesQuery = useSlides(projectIdStr); + const slidesQuery = useSlides(projectIdStr, { liveSync: false }); const slideAnalyticsQuery = useSlideAnalytics(projectIdNum); const summaryAnalyticsQuery = usePresentationAnalyticsSummary(projectIdNum); const recentCommentsQuery = useRecentComments(projectIdNum); diff --git a/src/hooks/usePresentationVideos.ts b/src/hooks/usePresentationVideos.ts index 44b3f6d2..107c884d 100644 --- a/src/hooks/usePresentationVideos.ts +++ b/src/hooks/usePresentationVideos.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import type { FilterMode, SortMode } from '@/types/home'; import type { VideoPresentation } from '@/types/video'; @@ -17,13 +18,20 @@ export function usePresentationVideos({ filter, sort, }: UsePresentationVideosParams) { + const normalizedFilter = filter && filter !== 'all' ? filter : undefined; + const normalizedSort = sort || undefined; + return useQuery({ - queryKey: ['videos', projectId, search, filter, sort], + queryKey: queryKeys.videos.list(projectId, { + search, + filter: normalizedFilter, + sort: normalizedSort, + }), queryFn: async () => { const response = await videosApi.getPresentationVideos(projectId, { search, - filter: filter && filter !== 'all' ? filter : undefined, - sort: sort || undefined, + filter: normalizedFilter, + sort: normalizedSort, }); if (response.data.resultType === 'FAILURE') { diff --git a/src/hooks/useScriptBulkEdit.ts b/src/hooks/useScriptBulkEdit.ts index f0236693..bd381361 100644 --- a/src/hooks/useScriptBulkEdit.ts +++ b/src/hooks/useScriptBulkEdit.ts @@ -1,12 +1,18 @@ import { type ChangeEvent, useCallback, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import type { ProjectScriptItemDto } from '@/api/dto'; +import { getProjectScripts } from '@/api/endpoints/scripts'; +import { queryKeys } from '@/api/queryClient'; import { useBulkEditScripts, useProjectScripts } from '@/hooks/queries/useScript'; import { useSlides } from '@/hooks/queries/useSlides'; import type { SlideListItem } from '@/types/slide'; import { showToast } from '@/utils/toast'; +const PROJECT_SCRIPTS_STALE_TIME_MS = 1000 * 60 * 10; + const normalizeTxt = (text: string) => text.replace(/^\uFEFF/, '').replace(/\r\n|\r/g, '\n'); const parseParagraphsFromTxt = (text: string) => @@ -27,10 +33,12 @@ const buildScriptMap = (scripts: ProjectScriptItemDto[] | undefined) => { export function useScriptBulkEdit() { const { projectId } = useParams<{ projectId: string }>(); - const { data: slides } = useSlides(projectId ?? ''); - const { data: projectScripts, refetch: refetchProjectScripts } = useProjectScripts( - projectId ?? '', - ); + const queryClient = useQueryClient(); + const { data: slides } = useSlides(projectId ?? '', { liveSync: false }); + const { data: projectScripts } = useProjectScripts(projectId ?? '', { + enabled: false, + staleTime: PROJECT_SCRIPTS_STALE_TIME_MS, + }); const { mutateAsync: bulkEditScripts, isPending: isSaving } = useBulkEditScripts(); @@ -72,8 +80,18 @@ export function useScriptBulkEdit() { setIsPreparingModal(true); try { - const response = await refetchProjectScripts(); - setDraftFromSource(response.data?.scripts ?? projectScripts?.scripts); + let latestScripts = projectScripts?.scripts; + + if (!latestScripts) { + const response = await queryClient.ensureQueryData({ + queryKey: queryKeys.scripts.project(projectId), + queryFn: () => getProjectScripts(projectId), + staleTime: PROJECT_SCRIPTS_STALE_TIME_MS, + }); + latestScripts = response.scripts; + } + + setDraftFromSource(latestScripts); } catch { setDraftFromSource(projectScripts?.scripts); showToast.error( diff --git a/src/hooks/useSlideSelectors.ts b/src/hooks/useSlideSelectors.ts index 4acc5be9..b87f46a2 100644 --- a/src/hooks/useSlideSelectors.ts +++ b/src/hooks/useSlideSelectors.ts @@ -14,6 +14,9 @@ const EMPTY_COMMENTS: Comment[] = []; /** 슬라이드 ID 구독 */ export const useSlideId = () => useSlideStore((state) => state.slide?.slideId ?? ''); +/** 프로젝트 ID 구독 */ +export const useSlideProjectId = () => useSlideStore((state) => state.slide?.projectId ?? ''); + /** 슬라이드 제목 구독 */ export const useSlideTitle = () => useSlideStore((state) => state.slide?.title ?? ''); diff --git a/src/pages/FeedbackVideoPage.tsx b/src/pages/FeedbackVideoPage.tsx index a3c99770..ca8c8f84 100644 --- a/src/pages/FeedbackVideoPage.tsx +++ b/src/pages/FeedbackVideoPage.tsx @@ -123,7 +123,6 @@ export default function FeedbackVideoPage({ currentTime={currentTime} onSeek={requestSeek} isLoading={isLoading} - variant="inverted" />
@@ -167,7 +166,6 @@ export default function FeedbackVideoPage({ slideChangeTimes={slideChangeTimes} currentTime={currentTime} onSeek={requestSeek} - variant="inverted" /> } commentTabContent={ diff --git a/src/pages/SlidePage.tsx b/src/pages/SlidePage.tsx index 64c20566..86f19e4a 100644 --- a/src/pages/SlidePage.tsx +++ b/src/pages/SlidePage.tsx @@ -10,7 +10,14 @@ export default function SlidePage() { const { projectId, slideId: routeSlideId } = useParams<{ projectId: string; slideId?: string }>(); const navigate = useNavigate(); - const { data: slides, isLoading, isError } = useSlides(projectId ?? ''); + const { + data: slides, + isLoading, + isError, + } = useSlides(projectId ?? '', { + liveSync: true, + pollingIntervalMs: 15000, + }); const currentSlide = slides?.find((s) => s.slideId === routeSlideId) ?? slides?.[0]; const currentIndex = currentSlide diff --git a/src/pages/VideoDetailPage.tsx b/src/pages/VideoDetailPage.tsx index df50378a..a99ec778 100644 --- a/src/pages/VideoDetailPage.tsx +++ b/src/pages/VideoDetailPage.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import type { ReadVideoDetailResponseDto, VideoCommentDto } from '@/api/dto/video.dto'; -import { getScript } from '@/api/endpoints/scripts'; import { videosApi } from '@/api/endpoints/videos'; import { CommentInput } from '@/components/comment'; import CommentList from '@/components/comment/CommentList'; @@ -10,6 +9,7 @@ import FeedbackMobileLayout from '@/components/feedback/FeedbackMobileLayout'; import ScriptSection from '@/components/feedback/ScriptSection'; import ReactionBubble from '@/components/feedback/video/ReactionBubble'; import SlideWebcamStage from '@/components/feedback/video/SlideWebcamStage'; +import { useProjectScripts } from '@/hooks/queries/useScript'; import { useSlides } from '@/hooks/queries/useSlides'; import { useIsDesktop } from '@/hooks/useMediaQuery'; import { useVideoComments } from '@/hooks/useVideoComments'; @@ -38,7 +38,11 @@ export default function VideoDetailPage() { const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [scrollToCommentId, setScrollToCommentId] = useState(); - const { data: slidesData } = useSlides(projectId!); + const { data: slidesData } = useSlides(projectId!, { liveSync: false }); + const { data: projectScripts } = useProjectScripts(projectId ?? '', { + enabled: !!projectId, + staleTime: 1000 * 60 * 10, + }); const [projectSlides, setProjectSlides] = useState([]); const [slideChangeTimes, setSlideChangeTimes] = useState([]); const [slideIdOrder, setSlideIdOrder] = useState([]); @@ -304,23 +308,24 @@ export default function VideoDetailPage() { useEffect(() => { if (!slidesData || slideIdOrder.length === 0) return; - const loadScripts = async () => { - const ordered = await Promise.all( - slideIdOrder.map(async (id) => { - const slideBase = slidesData.find((s) => String(s.slideId) === String(id)); - if (!slideBase) return null; - try { - const scriptRes = await getScript(String(id)); - return { ...slideBase, script: scriptRes.scriptText || '' }; - } catch { - return { ...slideBase, script: slideBase.script || '' }; - } - }), - ); - setProjectSlides(ordered.filter((s): s is SlideListItem => s !== null)); - }; - loadScripts(); - }, [slidesData, slideIdOrder]); + const slideMap = new Map(slidesData.map((slide) => [String(slide.slideId), slide])); + const projectScriptMap = new Map( + (projectScripts?.scripts ?? []).map((item) => [String(item.slideId), item.scriptText]), + ); + const orderedSlides = slideIdOrder + .map((id) => { + const slide = slideMap.get(String(id)); + if (!slide) return undefined; + + return { + ...slide, + script: projectScriptMap.get(String(id)) ?? slide.script ?? '', + }; + }) + .filter((slide): slide is SlideListItem => Boolean(slide)); + + setProjectSlides(orderedSlides); + }, [projectScripts, slideIdOrder, slidesData]); if (isLoading) return
Loading...
; diff --git a/src/pages/VideoListPage.tsx b/src/pages/VideoListPage.tsx index 2d6736a8..fcd36d22 100644 --- a/src/pages/VideoListPage.tsx +++ b/src/pages/VideoListPage.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { type QueryClient, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import { CardView, ListView, Spinner } from '@/components/common'; import PresentationCard from '@/components/presentation/PresentationCard'; import PresentationHeader from '@/components/presentation/PresentationHeader'; @@ -11,17 +13,36 @@ import PresentationList from '@/components/presentation/PresentationList'; import { DeleteVideoModal, RecordingEmptySection } from '@/components/video'; import { usePresentationVideos } from '@/hooks/usePresentationVideos'; import type { FilterMode, SortMode, ViewMode } from '@/types/home'; +import type { VideoPresentation } from '@/types/video'; import { showToast } from '@/utils/toast'; const SKELETON_CARD_COUNT = 6; const SKELETON_LIST_COUNT = 4; type DeleteTarget = { id: string; title: string } | null; +type VideoListQueryData = { videos: VideoPresentation[]; total: number }; + +function updateVideoListCache( + queryClient: QueryClient, + projectId: string, + updater: (data: VideoListQueryData) => VideoListQueryData | undefined, +) { + queryClient.setQueriesData( + { + queryKey: queryKeys.videos.listPrefix(projectId), + }, + (oldData) => { + if (!oldData) return undefined; + return updater(oldData); + }, + ); +} export default function VideoListPage() { const navigate = useNavigate(); const location = useLocation(); const { projectId } = useParams<{ projectId: string }>(); + const queryClient = useQueryClient(); // UI 상태 const [query, setQuery] = useState(''); @@ -72,8 +93,12 @@ export default function VideoListPage() { showToast.success('영상을 저장했습니다.'); navigate(location.pathname, { replace: true, state: {} }); - void refetch(); - }, [location.state, location.pathname, navigate, refetch]); + if (!projectId) return; + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + }); + }, [location.state, location.pathname, navigate, projectId, queryClient]); // processing 1시간 초과면 stuck 처리 (파생 데이터로 정리) const videos = useMemo(() => { @@ -181,10 +206,34 @@ export default function VideoListPage() { const handleUpdateVideoTitle = useCallback( async (videoId: string, newTitle: string) => { - await videosApi.updateVideoTitle(videoId, newTitle); - await refetch(); + if (!projectId) return; + + const updatedVideo = await videosApi.updateVideoTitle(videoId, newTitle); + updateVideoListCache(queryClient, projectId, (oldData) => { + let hasUpdated = false; + const nextVideos = oldData.videos.map((video) => { + if (String(video.videoId) !== String(videoId)) return video; + hasUpdated = true; + return { + ...video, + title: updatedVideo.title, + updatedAt: updatedVideo.updatedAt, + }; + }); + + if (!hasUpdated) return oldData; + + return { + ...oldData, + videos: nextVideos, + }; + }); + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + }); }, - [refetch], + [projectId, queryClient], ); const openDeleteModal = useCallback((id: string, title: string) => { @@ -199,6 +248,7 @@ export default function VideoListPage() { const handleConfirmDelete = useCallback(async () => { if (!videoToDelete) return; + if (!projectId) return; const { id } = videoToDelete; @@ -213,7 +263,25 @@ export default function VideoListPage() { } showToast.success('영상을 삭제했습니다.'); - void refetch(); + updateVideoListCache(queryClient, projectId, (oldData) => { + const nextVideos = oldData.videos.filter((video) => String(video.videoId) !== String(id)); + if (nextVideos.length === oldData.videos.length) return oldData; + + return { + ...oldData, + videos: nextVideos, + total: Math.max(0, oldData.total - 1), + }; + }); + setPendingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + + void queryClient.invalidateQueries({ + queryKey: queryKeys.videos.listPrefix(projectId), + }); } catch (err) { showToast.error( '영상을 삭제하지 못했습니다.', @@ -227,7 +295,7 @@ export default function VideoListPage() { }); setVideoToDelete(null); } - }, [videoToDelete, closeDeleteModal, refetch]); + }, [videoToDelete, projectId, closeDeleteModal, queryClient]); const renderSkeleton = () => { if (viewMode === 'card') { diff --git a/src/pages/VideoRecordPage.tsx b/src/pages/VideoRecordPage.tsx index ea268308..01da2075 100644 --- a/src/pages/VideoRecordPage.tsx +++ b/src/pages/VideoRecordPage.tsx @@ -15,6 +15,7 @@ import { } from '@/components/video'; import { getTabPath } from '@/constants/navigation'; import { usePresentation } from '@/hooks/queries/usePresentations'; +import { useProjectEntryPrefetch } from '@/hooks/queries/useProjectEntryPrefetch'; import { useVideoUpload } from '@/hooks/useVideoUpload'; type RecordStep = 'TEST' | 'RECORDING'; @@ -24,6 +25,8 @@ export default function VideoRecordPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); + useProjectEntryPrefetch(projectId); + const { data: presentation } = usePresentation(projectId!); const [step, setStep] = useState('TEST'); @@ -102,7 +105,7 @@ export default function VideoRecordPage() { if (projectId) { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.shares.videos(projectId) }), - queryClient.invalidateQueries({ queryKey: queryKeys.videos.list(projectId) }), + queryClient.invalidateQueries({ queryKey: queryKeys.videos.listPrefix(projectId) }), ]); } diff --git a/src/pages/feedback/useFeedbackSlide.ts b/src/pages/feedback/useFeedbackSlide.ts index 1ee14f9a..0eaa57de 100644 --- a/src/pages/feedback/useFeedbackSlide.ts +++ b/src/pages/feedback/useFeedbackSlide.ts @@ -1,17 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + import { slideView } from '@/api/endpoints/analytics'; import { createReply } from '@/api/endpoints/comments'; -import { getSharedComments } from '@/api/endpoints/shares'; +import { queryKeys } from '@/api/queryClient'; import { createDefaultReactions } from '@/constants/reaction'; import { useHotkey, useSlideComments } from '@/hooks'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; import { useSlideCommentsActions } from '@/hooks/useSlideCommentsActions'; import { useSlideNavigation } from '@/hooks/useSlideNavigation'; import { useSlideReactions } from '@/hooks/useSlideReactions'; import { useAuthStore } from '@/stores/authStore'; import { useSlideStore } from '@/stores/slideStore'; import type { Comment } from '@/types/comment'; -import type { SharedPresentationComment, SharedPresentationSlide } from '@/types/share'; +import type { + ReadSharedCommentsData, + SharedPresentationComment, + SharedPresentationSlide, +} from '@/types/share'; import { flatToTree } from '@/utils/comment'; import { normalizeSharedSlides } from '@/utils/sharedContent'; import { showToast } from '@/utils/toast'; @@ -62,6 +69,8 @@ export const useFeedbackSlide = ({ shareToken, onShareExitSnapshotChange, }: UseFeedbackSlideOptions) => { + const queryClient = useQueryClient(); + const sessionId = useAuthStore((state) => state.user?.sessionId); const slides = useMemo(() => normalizeSharedSlides(sharedSlides), [sharedSlides]); const totalSlides = slides.length; @@ -123,21 +132,35 @@ export const useFeedbackSlide = ({ ); }, [slides]); - // 서버에서 최신 댓글 목록을 가져와서 store 업데이트 - const reloadComments = useCallback(async () => { + const { data: sharedCommentsData, isFetching: isCommentsLoading } = useSharedComments( + encodeURIComponent(shareToken ?? ''), + encodeURIComponent(sessionId ?? ''), + { + enabled: !!shareToken, + initialData: { + comments: sharedComments, + }, + staleTime: 0, + }, + ); + + // 서버 댓글 목록을 store에 동기화 + useEffect(() => { + if (!sharedCommentsData) return; + const slideComments = mapSharedSlideComments(sharedCommentsData.comments, sharedSlideMeta); + setComments(slideComments); + }, [setComments, sharedCommentsData, sharedSlideMeta]); + + const invalidateSharedComments = useCallback(async (): Promise => { if (!shareToken) return null; + const sharedCommentsKey = queryKeys.shares.comments(shareToken, sessionId); - try { - const { user } = useAuthStore.getState(); - const sessionId = user?.sessionId; - const data = await getSharedComments(shareToken, sessionId); - const slideComments = mapSharedSlideComments(data.comments, sharedSlideMeta); - setComments(slideComments); - return data.comments; - } catch { - return null; - } - }, [shareToken, setComments, sharedSlideMeta]); + await queryClient.invalidateQueries({ + queryKey: sharedCommentsKey, + }); + + return queryClient.getQueryData(sharedCommentsKey) ?? null; + }, [queryClient, sessionId, shareToken]); const { addComment, deleteComment, updateComment } = useSlideCommentsActions(); @@ -161,7 +184,7 @@ export const useFeedbackSlide = ({ const handleAddComment = async () => { if (!commentDraft.trim()) return; const serverId = await addComment(commentDraft); - await reloadComments(); + await invalidateSharedComments(); if (serverId) { setScrollToCommentId(serverId); } @@ -191,7 +214,7 @@ export const useFeedbackSlide = ({ // 최상위 부모의 serverId로 답글 작성 try { const response = await createReply(rootParentServerId, { content: trimmedContent }); - await reloadComments(); + await invalidateSharedComments(); // 작성한 답글로 스크롤 if (response.replyId) { @@ -204,22 +227,14 @@ export const useFeedbackSlide = ({ const handleDeleteComment = async (commentId: string) => { await deleteComment(commentId); - await reloadComments(); + await invalidateSharedComments(); }; const handleUpdateComment = async (commentId: string, content: string) => { await updateComment(commentId, content); - await reloadComments(); + await invalidateSharedComments(); }; - // 초기 댓글 로딩 - useEffect(() => { - const initialComments = mapSharedSlideComments(sharedComments, sharedSlideMeta); - if (initialComments.length > 0) { - setComments(initialComments); - } - }, [sharedComments, sharedSlideMeta, setComments]); - const handleGoToRef = useCallback( (ref: NonNullable) => { if (ref.kind !== 'slide') return; @@ -285,7 +300,7 @@ export const useFeedbackSlide = ({ scrollToCommentId, reactions, isLoading: false, - isCommentsLoading: false, + isCommentsLoading, commentsHasNextPage: false, commentsIsFetchingNextPage: false, isFirst: navigation.isFirst, diff --git a/src/pages/feedback/useFeedbackVideo.test.tsx b/src/pages/feedback/useFeedbackVideo.test.tsx new file mode 100644 index 00000000..c56ec11c --- /dev/null +++ b/src/pages/feedback/useFeedbackVideo.test.tsx @@ -0,0 +1,237 @@ +import { useParams } from 'react-router-dom'; + +import { useQueryClient } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { recordVideoEvent } from '@/api/endpoints/analytics'; +import { videosApi } from '@/api/endpoints/videos'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; +import { useVideoComments } from '@/hooks/useVideoComments'; +import { useVideoReactions } from '@/hooks/useVideoReactions'; +import { useVideoFeedbackStore } from '@/stores/videoFeedbackStore'; +import type { ReadSharedContentData } from '@/types/share'; + +import { useFeedbackVideo } from './useFeedbackVideo'; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: vi.fn(), + }; +}); + +vi.mock('@tanstack/react-query', async () => { + const actual = + await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +vi.mock('@/api/endpoints/analytics', () => ({ + recordVideoEvent: vi.fn(), +})); + +vi.mock('@/api/endpoints/videos', () => ({ + videosApi: { + getVideoDetail: vi.fn(), + getVideoSlides: vi.fn(), + }, +})); + +vi.mock('@/hooks/queries/useSharedComments', () => ({ + useSharedComments: vi.fn(), +})); + +vi.mock('@/hooks/useVideoComments', () => ({ + useVideoComments: vi.fn(), +})); + +vi.mock('@/hooks/useVideoReactions', () => ({ + useVideoReactions: vi.fn(), +})); + +const mockedUseParams = vi.mocked(useParams); +const mockedUseQueryClient = vi.mocked(useQueryClient); +const mockedRecordVideoEvent = vi.mocked(recordVideoEvent); +const mockedVideosApi = vi.mocked(videosApi); +const mockedUseSharedComments = vi.mocked(useSharedComments); +const mockedUseVideoComments = vi.mocked(useVideoComments); +const mockedUseVideoReactions = vi.mocked(useVideoReactions); + +const baseSharedContent: ReadSharedContentData = { + message: 'ok', + sessionInfo: { + sessionId: 'session-1', + name: 'anonymous', + tokens: { + accessToken: 'a', + refreshToken: 'b', + }, + }, + shareInfo: { + shareToken: 'share-token', + scope: 'slides_script_video', + createdAt: '2025-01-01T00:00:00.000Z', + publisherName: 'tester', + }, + presentationContent: { + title: '공유 영상', + slides: [ + { + slideId: '1', + slideNum: 1, + title: '슬라이드 1', + imageUrl: 'https://example.com/s1.png', + scriptText: 'script 1', + timestampMs: 0, + }, + { + slideId: '2', + slideNum: 2, + title: '슬라이드 2', + imageUrl: 'https://example.com/s2.png', + scriptText: 'script 2', + timestampMs: 5000, + }, + ], + video: { + videoId: '101', + videoUrl: 'https://cdn.ttorang.com/video.m3u8', + thumbnailUrl: null, + }, + comments: [], + }, +}; + +describe('useFeedbackVideo', () => { + const invalidateQueriesMock = vi.fn().mockResolvedValue(undefined); + const getQueryDataMock = vi.fn(); + const addCommentMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useVideoFeedbackStore.setState({ video: null, currentTime: 0, seekTo: null }); + + mockedUseParams.mockReturnValue({ shareToken: 'share-token' }); + mockedUseQueryClient.mockReturnValue({ + invalidateQueries: invalidateQueriesMock, + getQueryData: getQueryDataMock, + } as never); + + mockedUseVideoComments.mockReturnValue({ + comments: [], + addComment: addCommentMock, + addReply: vi.fn(), + deleteComment: vi.fn(), + updateComment: vi.fn(), + }); + + mockedUseVideoReactions.mockReturnValue({ + reactions: [], + addReaction: vi.fn(), + }); + + mockedUseSharedComments.mockReturnValue({ + data: { comments: [] }, + } as never); + + mockedVideosApi.getVideoDetail.mockResolvedValue({ + data: { + resultType: 'SUCCESS', + success: { + video: { + title: '서버 영상', + durationSeconds: 120, + hlsMasterUrl: 'https://cdn.ttorang.com/server.m3u8', + }, + }, + }, + } as never); + mockedVideosApi.getVideoSlides.mockResolvedValue({ + data: { + resultType: 'SUCCESS', + success: { + slides: [ + { slideId: '1', timestampMs: 0 }, + { slideId: '2', timestampMs: 4000 }, + ], + }, + }, + } as never); + }); + + it('공유 슬라이드 타임라인이 있으면 video slides API를 추가 호출하지 않는다', async () => { + const { result } = renderHook(() => useFeedbackVideo(baseSharedContent)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockedVideosApi.getVideoSlides).not.toHaveBeenCalled(); + expect(mockedVideosApi.getVideoDetail).not.toHaveBeenCalled(); + }); + + it('타임라인/영상 URL이 없으면 필요한 API를 1회씩 호출한다', async () => { + const contentWithoutTimeline: ReadSharedContentData = { + ...baseSharedContent, + presentationContent: { + ...baseSharedContent.presentationContent, + slides: baseSharedContent.presentationContent.slides.map((slide) => ({ + ...slide, + timestampMs: -1, + })), + video: { + videoId: '101', + videoUrl: null, + thumbnailUrl: null, + }, + }, + }; + + const { result } = renderHook(() => useFeedbackVideo(contentWithoutTimeline)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockedVideosApi.getVideoDetail).toHaveBeenCalledTimes(1); + expect(mockedVideosApi.getVideoSlides).toHaveBeenCalledTimes(1); + }); + + it('공유 제목이 비어 있으면 상세 API를 호출해 제목을 보정한다', async () => { + const contentWithoutTitle: ReadSharedContentData = { + ...baseSharedContent, + presentationContent: { + ...baseSharedContent.presentationContent, + title: '', + }, + }; + + const { result } = renderHook(() => useFeedbackVideo(contentWithoutTitle)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockedVideosApi.getVideoDetail).toHaveBeenCalledTimes(1); + expect(mockedVideosApi.getVideoSlides).not.toHaveBeenCalled(); + }); + + it('댓글 등록 시 공유 댓글 invalidate를 1회 호출한다', async () => { + addCommentMock.mockResolvedValue('comment-1'); + getQueryDataMock.mockReturnValue({ + comments: [{ commentId: 'comment-1' }], + }); + + const { result } = renderHook(() => useFeedbackVideo(baseSharedContent)); + + await act(async () => { + result.current.setCommentDraft('테스트 댓글'); + }); + + await act(async () => { + await result.current.handleAddComment(); + }); + + expect(invalidateQueriesMock).toHaveBeenCalledTimes(1); + expect(mockedRecordVideoEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/feedback/useFeedbackVideo.ts b/src/pages/feedback/useFeedbackVideo.ts index a27f5f70..fadda7e4 100644 --- a/src/pages/feedback/useFeedbackVideo.ts +++ b/src/pages/feedback/useFeedbackVideo.ts @@ -5,16 +5,23 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import { recordVideoEvent } from '@/api/endpoints/analytics'; -import { getSharedComments } from '@/api/endpoints/shares'; import { videosApi } from '@/api/endpoints/videos'; +import { queryKeys } from '@/api/queryClient'; import { createDefaultReactions } from '@/constants/reaction'; +import { useSharedComments } from '@/hooks/queries/useSharedComments'; import { useVideoComments } from '@/hooks/useVideoComments'; import { useVideoReactions } from '@/hooks/useVideoReactions'; import { useAuthStore } from '@/stores/authStore'; import { useVideoFeedbackStore } from '@/stores/videoFeedbackStore'; import type { Comment } from '@/types/comment'; -import type { ReadSharedContentData, SharedPresentationComment } from '@/types/share'; +import type { + ReadSharedCommentsData, + ReadSharedContentData, + SharedPresentationComment, +} from '@/types/share'; import type { SlideDetail } from '@/types/slide'; import type { VideoTimestampFeedback } from '@/types/video'; import { formatVideoTimestamp } from '@/utils/format'; @@ -165,9 +172,11 @@ export function useFeedbackVideo( options: UseFeedbackVideoOptions = {}, ) { const { onShareExitSnapshotChange } = options; + const queryClient = useQueryClient(); // ─── 라우트 파라미터 ─────────────────────────────────── const { shareToken = '' } = useParams<{ shareToken?: string }>(); + const sessionId = useAuthStore((state) => state.user?.sessionId); // ─── Store 셀렉터 ───────────────────────────────────── const video = useVideoFeedbackStore((s) => s.video); @@ -212,19 +221,25 @@ export function useFeedbackVideo( const videoId = content.presentationContent.video?.videoId ?? ''; const normalizedVideoId = String(videoId); let videoUrl = toPlayableVideoUrl(content.presentationContent.video?.videoUrl); - let videoTitle = content.presentationContent.title || '공유 영상'; + const hasOriginalTitle = + typeof content.presentationContent.title === 'string' && + content.presentationContent.title.trim().length > 0; + let videoTitle = hasOriginalTitle ? content.presentationContent.title : '공유 영상'; let duration = 0; let timelineSlides: Array<{ slideId: string; timestampMs: number }> = fallbackTimelineSlides; + const hasSharedTimeline = fallbackTimelineSlides.length > 0; + const needsVideoDetail = !videoUrl || !hasOriginalTitle; if (normalizedVideoId) { const [detailResult, timelineResult] = await Promise.allSettled([ - videosApi.getVideoDetail(normalizedVideoId), - videosApi.getVideoSlides(normalizedVideoId), + needsVideoDetail ? videosApi.getVideoDetail(normalizedVideoId) : Promise.resolve(null), + !hasSharedTimeline ? videosApi.getVideoSlides(normalizedVideoId) : Promise.resolve(null), ]); if (cancelled) return; if ( detailResult.status === 'fulfilled' && + detailResult.value && detailResult.value.data.resultType === 'SUCCESS' ) { const serverVideo = detailResult.value.data.success.video; @@ -235,6 +250,7 @@ export function useFeedbackVideo( if ( timelineResult.status === 'fulfilled' && + timelineResult.value && timelineResult.value.data.resultType === 'SUCCESS' ) { timelineSlides = timelineResult.value.data.success.slides.map((slide) => ({ @@ -278,6 +294,19 @@ export function useFeedbackVideo( }; }, [initVideo, sharedContent, shareToken]); + const { data: sharedCommentsData } = useSharedComments(shareToken, sessionId, { + enabled: !!shareToken, + initialData: { + comments: sharedContent.presentationContent.comments, + }, + }); + + useEffect(() => { + if (!shareToken || !sharedCommentsData) return; + const sharedFeedbacks = mapSharedCommentsToFeedbacks(sharedCommentsData.comments); + updateFeedbacks(sharedFeedbacks); + }, [shareToken, sharedCommentsData, updateFeedbacks]); + // ─── 리액션 ─────────────────────────────────────────── const { reactions, addReaction } = useVideoReactions(); @@ -287,21 +316,21 @@ export function useFeedbackVideo( const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [capturedTimestamp, setCapturedTimestamp] = useState(null); - const reloadComments = useCallback(async () => { + const invalidateSharedComments = useCallback(async (): Promise => { if (!shareToken) return null; - try { - const { user } = useAuthStore.getState(); - const data = await getSharedComments(shareToken, user?.sessionId); - const sharedFeedbacks = mapSharedCommentsToFeedbacks(data.comments); - updateFeedbacks(sharedFeedbacks); - return data.comments; - } catch { - return null; - } - }, [shareToken, updateFeedbacks]); + const sharedCommentsKey = queryKeys.shares.comments(shareToken, sessionId); + + await queryClient.invalidateQueries({ + queryKey: sharedCommentsKey, + }); + + return queryClient.getQueryData(sharedCommentsKey) ?? null; + }, [queryClient, sessionId, shareToken]); const { comments, addComment, addReply, deleteComment, updateComment } = useVideoComments({ - onMutationSuccess: () => void reloadComments(), + onMutationSuccess: () => { + void invalidateSharedComments(); + }, }); const handleInputFocus = useCallback(() => { @@ -317,13 +346,15 @@ export function useFeedbackVideo( try { const timestampToUse = capturedTimestamp ?? currentTime; const newCommentServerId = await addComment(commentDraft, timestampToUse); + if (!newCommentServerId) return; + setCommentDraft(''); setCapturedTimestamp(null); - const latestComments = await reloadComments(); + const latestComments = await invalidateSharedComments(); - if (newCommentServerId && latestComments) { - const newComment = latestComments.find( + if (latestComments) { + const newComment = latestComments.comments.find( (c: SharedPresentationComment) => c.commentId === newCommentServerId, ); if (newComment) { @@ -334,7 +365,7 @@ export function useFeedbackVideo( } finally { setIsSubmittingComment(false); } - }, [addComment, commentDraft, currentTime, capturedTimestamp, reloadComments]); + }, [addComment, commentDraft, currentTime, capturedTimestamp, invalidateSharedComments]); const handleCancelComment = useCallback(() => { setCommentDraft(''); diff --git a/src/pages/requestOptimizationRegression.test.ts b/src/pages/requestOptimizationRegression.test.ts new file mode 100644 index 00000000..b9d74c96 --- /dev/null +++ b/src/pages/requestOptimizationRegression.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import recordingSectionSource from '@/components/video/RecordingSection.tsx?raw'; +import scriptBulkEditSource from '@/hooks/useScriptBulkEdit.ts?raw'; +import feedbackVideoPageSource from '@/pages/FeedbackVideoPage.tsx?raw'; +import videoDetailPageSource from '@/pages/VideoDetailPage.tsx?raw'; +import feedbackSlideSource from '@/pages/feedback/useFeedbackSlide.ts?raw'; +import feedbackVideoSource from '@/pages/feedback/useFeedbackVideo.ts?raw'; + +describe('API request optimization regression guards', () => { + it('RecordingSection은 개별 대본 쿼리를 사용하지 않는다', () => { + expect(recordingSectionSource).not.toMatch(/useScript\(/); + expect(recordingSectionSource).not.toMatch(/getScript\(/); + }); + + it('VideoDetailPage는 슬라이드별 getScript 병렬 호출을 하지 않는다', () => { + expect(videoDetailPageSource).not.toMatch(/getScript\(/); + }); + + it('공유 피드백 훅은 useSharedComments를 통해 댓글을 조회한다', () => { + expect(feedbackVideoSource).toMatch(/useSharedComments\(/); + expect(feedbackSlideSource).toMatch(/useSharedComments\(/); + expect(feedbackVideoSource).not.toMatch(/getSharedComments\(/); + expect(feedbackSlideSource).not.toMatch(/getSharedComments\(/); + }); + + it('대본 일괄 수정 훅은 마운트 시 scripts 자동 재요청을 하지 않는다', () => { + expect(scriptBulkEditSource).toMatch(/useProjectScripts\([^)]*enabled:\s*false/); + expect(scriptBulkEditSource).not.toMatch(/refetchProjectScripts/); + }); + + it('공유 비디오 페이지 대본 섹션은 현재 시점 강조 스타일을 기본 규칙으로 사용한다', () => { + expect(feedbackVideoPageSource).not.toMatch(/variant="inverted"/); + }); +});