From af8a7d77829033c2f11db79b90b3a0b2a30abd80 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 5 May 2026 00:16:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(scrap):=20MAT-524=20race-B=20=EB=B4=89?= =?UTF-8?q?=EC=87=84=20+=20=EB=A7=A4=EB=8B=88=EC=A0=80=20=EC=9E=AC?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - race 가드 ref 3개 (appliedScrapIdRef, pendingLoadRef, needsSaveRef) — 데이터 적용 전 PUT 차단 - pure helper (evaluateFlush, canMark) 매니저 inline — race 가드 dispatch - autosave fire-and-forget / 명시 flush mutateAsync + 5s timeout (silent, 실패 시 toast) - decode 실패 시 전화면 에러 + PUT 차단 - key={drawing-canvas-\${scrapId}} 제거, 매니저 imperative reset - canvas mount: callback ref + boolean state (drawing.tsx useImperativeHandle deps 없는 문제 우회) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scrap/hooks/useHandwritingManager.ts | 346 +++++++++++------- .../scrap/screens/ScrapDetailScreen.tsx | 105 ++---- 2 files changed, 261 insertions(+), 190 deletions(-) diff --git a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts index d5e396044..94e0b0d13 100644 --- a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts +++ b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts @@ -1,154 +1,252 @@ -import { useEffect, useCallback, useRef } from 'react'; -import { Alert, AppState, type AppStateStatus } from 'react-native'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useGetHandwriting, useUpdateHandwriting } from '@/apis'; +import { client, TanstackQueryClient } from '@/apis/client'; +import { useGetHandwriting } from '@/apis'; +import { type paths } from '@schema'; -import { type DrawingCanvasRef } from '../utils/skia/drawing'; -import { encodeHandwritingData, decodeHandwritingData } from '../utils/handwritingEncoder'; +import { showToast } from '../components/Notification/Toast'; +import { type DrawingCanvasRef, type Stroke, type TextItem } from '../utils/skia/drawing'; +import { decodeHandwritingData, encodeHandwritingData } from '../utils/handwritingEncoder'; + +type UpdateHandwritingRequest = + paths['/api/student/scrap/{scrapId}/handwriting']['put']['requestBody']['content']['application/json']; +type UpdateHandwritingResponse = + paths['/api/student/scrap/{scrapId}/handwriting']['put']['responses']['200']['content']['*/*']; + +interface UpdateHandwritingParams { + scrapId: number; + request: UpdateHandwritingRequest; +} + +const AUTOSAVE_INTERVAL_MS = 5000; +const FLUSH_TIMEOUT_MS = 5000; + +type FlushDecision = + | 'allow' + | 'no-needs-save' + | 'pending-load' + | 'decode-error' + | 'scrap-id-mismatch' + | 'no-canvas'; + +type FlushContext = { + needsSave: boolean; + pendingLoad: boolean; + decodeError: string | null; + appliedScrapId: number | null; + currentScrapId: number; + hasCanvas: boolean; +}; + +type MarkContext = { + pendingLoad: boolean; + appliedScrapId: number | null; + currentScrapId: number; + decodeError: string | null; +}; + +function evaluateFlush(ctx: FlushContext): FlushDecision { + if (!ctx.needsSave) return 'no-needs-save'; + if (ctx.pendingLoad) return 'pending-load'; + if (ctx.decodeError) return 'decode-error'; + if (ctx.appliedScrapId !== ctx.currentScrapId) return 'scrap-id-mismatch'; + if (!ctx.hasCanvas) return 'no-canvas'; + return 'allow'; +} + +function canMark(ctx: MarkContext): boolean { + if (ctx.pendingLoad) return false; + if (ctx.appliedScrapId !== ctx.currentScrapId) return false; + if (ctx.decodeError) return false; + return true; +} export interface UseHandwritingManagerProps { scrapId: number; - canvasRef: React.RefObject; - hasUnsavedChanges: boolean; - onSaveSuccess?: () => void; - onSaveError?: () => void; } -export function useHandwritingManager({ - scrapId, - canvasRef, - hasUnsavedChanges, - onSaveSuccess, - onSaveError, -}: UseHandwritingManagerProps) { +export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { + const queryClient = useQueryClient(); const { data: handwritingData, isLoading } = useGetHandwriting(scrapId, !!scrapId); - const { mutate: updateHandwriting, isPending: isSaving } = useUpdateHandwriting(); - const lastSavedDataRef = useRef(''); - const currentScrapIdRef = useRef(scrapId); - // scrapId가 변경되면 lastSavedDataRef 초기화 + // canvas 인스턴스는 ref 로, mount 사실은 boolean state 로 분리. + // useImperativeHandle (drawing.tsx) 가 deps 없이 매 render 마다 새 object + // 로 ref 를 업데이트하므로 object 자체를 useState 에 담으면 매 render 마다 + // setState → 무한 루프. boolean 은 같은 값일 때 React 가 dedupe → 안전. + const canvasRef = useRef(null); + const [canvasMounted, setCanvasMounted] = useState(false); + const setCanvasRef = useCallback((node: DrawingCanvasRef | null) => { + canvasRef.current = node; + setCanvasMounted(node !== null); + }, []); + + const updateMutation = useMutation({ + mutationFn: async ({ + scrapId: id, + request, + }: UpdateHandwritingParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { + params: { path: { scrapId: id } }, + body: request, + }); + return data as UpdateHandwritingResponse; + }, + onSuccess: (response, { scrapId: id }) => { + queryClient.setQueryData( + TanstackQueryClient.queryOptions('get', '/api/student/scrap/{scrapId}/handwriting', { + params: { path: { scrapId: id } }, + }).queryKey, + response + ); + }, + onError: () => { + showToast('error', '자동저장에 실패했어요'); + }, + }); + + const needsSaveRef = useRef(false); + const pendingLoadRef = useRef(false); + const appliedScrapIdRef = useRef(null); + const [decodeError, setDecodeError] = useState(null); + + // decodeError / updateMutation 을 ref 로 미러링 — 콜백 deps 에서 제외해 + // 매 render 마다 새 reference 가 만들어지는 걸 막음 (DrawingCanvas 의 + // notifyHistoryChange 가 prop 변경에 따라 새 reference 가 되어 캔버스 내부 + // useEffect 가 재발화 → onHistoryChange 콜백 호출 → setState → 무한 루프). + const decodeErrorRef = useRef(null); + decodeErrorRef.current = decodeError; + const updateMutationRef = useRef(updateMutation); + updateMutationRef.current = updateMutation; + + const applyData = useCallback( + (strokes: Stroke[], texts: TextItem[]) => { + const c = canvasRef.current; + if (!c) return; + pendingLoadRef.current = true; + try { + c.setStrokes(strokes); + c.setTexts(texts); + } finally { + pendingLoadRef.current = false; + } + appliedScrapIdRef.current = scrapId; + needsSaveRef.current = false; + }, + [scrapId] + ); + useEffect(() => { - if (currentScrapIdRef.current !== scrapId) { - lastSavedDataRef.current = ''; - currentScrapIdRef.current = scrapId; + appliedScrapIdRef.current = null; + needsSaveRef.current = false; + setDecodeError(null); + pendingLoadRef.current = true; + try { + canvasRef.current?.clear(); + } finally { + pendingLoadRef.current = false; } }, [scrapId]); - // 필기 데이터 로드 useEffect(() => { - // 저장 중이 아니고, scrapId가 일치할 때만 로드 (데이터 유실 방지) - if ( - handwritingData?.data && - canvasRef.current && - currentScrapIdRef.current === scrapId && - !isSaving - ) { - // clear() 완료를 보장하기 위해 약간의 지연 후 로드 - const loadTimer = setTimeout(() => { - // 다시 한번 scrapId 확인 (clear() 실행 중일 수 있음) - if (currentScrapIdRef.current === scrapId && canvasRef.current && !isSaving) { - try { - const decodedData = decodeHandwritingData(handwritingData.data); - canvasRef.current.setStrokes(decodedData.strokes); - canvasRef.current.setTexts(decodedData.texts); - lastSavedDataRef.current = handwritingData.data; - } catch (error) { - console.error('필기 데이터 로드 실패:', error); - } - } - }, 50); // 50ms 지연으로 clear() 완료 보장 - - return () => clearTimeout(loadTimer); + if (handwritingData === undefined) return; + if (appliedScrapIdRef.current === scrapId) return; + if (!canvasMounted) return; + try { + const decoded = handwritingData?.data + ? decodeHandwritingData(handwritingData.data) + : { strokes: [] as Stroke[], texts: [] as TextItem[] }; + applyData(decoded.strokes, decoded.texts); + } catch (e) { + console.error('[handwriting] decode failed', e); + setDecodeError('필기를 불러오지 못했어요.'); } - }, [handwritingData, canvasRef, scrapId]); + }, [handwritingData, scrapId, applyData, canvasMounted]); - // 저장하기 함수 - const handleSave = useCallback( - (isAutoSave = false, targetScrapId?: number) => { - if (!canvasRef.current) return Promise.resolve(false); + const markNeedsSave = useCallback(() => { + if ( + !canMark({ + pendingLoad: pendingLoadRef.current, + appliedScrapId: appliedScrapIdRef.current, + currentScrapId: scrapId, + decodeError: decodeErrorRef.current, + }) + ) + return; + needsSaveRef.current = true; + }, [scrapId]); - // 이미 저장 중이면 중복 저장 방지 - if (isSaving) { - return Promise.resolve(false); - } + const flushFireAndForget = useCallback(() => { + const c = canvasRef.current; + const decision = evaluateFlush({ + needsSave: needsSaveRef.current, + pendingLoad: pendingLoadRef.current, + decodeError: decodeErrorRef.current, + appliedScrapId: appliedScrapIdRef.current, + currentScrapId: scrapId, + hasCanvas: !!c, + }); + if (decision !== 'allow' || !c) return; + const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []); + needsSaveRef.current = false; + updateMutationRef.current.mutate({ scrapId, request: { data } }); + }, [scrapId]); - const strokes = canvasRef.current.getStrokes(); - const texts = canvasRef.current.getTexts(); + const flushAwait = useCallback(async (): Promise => { + const c = canvasRef.current; + const decision = evaluateFlush({ + needsSave: needsSaveRef.current, + pendingLoad: pendingLoadRef.current, + decodeError: decodeErrorRef.current, + appliedScrapId: appliedScrapIdRef.current, + currentScrapId: scrapId, + hasCanvas: !!c, + }); + if (decision !== 'allow' || !c) return true; + + const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []); + needsSaveRef.current = false; + + try { + await Promise.race([ + updateMutationRef.current.mutateAsync({ scrapId, request: { data } }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('flush-timeout')), FLUSH_TIMEOUT_MS) + ), + ]); + return true; + } catch { + needsSaveRef.current = true; + showToast('error', '저장에 실패했어요'); + return true; + } + }, [scrapId]); - try { - const base64Data = encodeHandwritingData(strokes || [], texts || []); - - // 변경사항 없으면 저장 안 함 - if (base64Data === lastSavedDataRef.current) { - if (!isAutoSave) { - Alert.alert('알림', '변경사항이 없습니다.'); - } - return Promise.resolve(true); - } - - // targetScrapId가 제공되면 그것을 사용, 아니면 scrapId 사용 - const saveScrapId = targetScrapId ?? scrapId; - - return new Promise((resolve) => { - updateHandwriting( - { - scrapId: saveScrapId, - request: { data: base64Data }, - }, - { - onSuccess: () => { - lastSavedDataRef.current = base64Data; - onSaveSuccess?.(); - if (!isAutoSave) { - Alert.alert('성공', '필기가 저장되었습니다.'); - } - resolve(true); - }, - onError: (error) => { - console.error('필기 저장 실패:', error); - onSaveError?.(); - if (!isAutoSave) { - Alert.alert('오류', '필기 저장에 실패했습니다.'); - } - resolve(false); - }, - } - ); - }); - } catch (error) { - console.error('필기 데이터 변환 실패:', error); - if (!isAutoSave) { - Alert.alert('오류', '필기 데이터 변환에 실패했습니다.'); - } - return Promise.resolve(false); - } - }, - [scrapId, canvasRef, updateHandwriting, onSaveSuccess, onSaveError, isSaving] - ); + const flushFireAndForgetRef = useRef(flushFireAndForget); + flushFireAndForgetRef.current = flushFireAndForget; - // 5초마다 자동 저장 useEffect(() => { - const autoSaveInterval = setInterval(() => { - if (hasUnsavedChanges && !isSaving) { - handleSave(true); - } - }, 5000); // 5초마다 실행 + const id = setInterval(() => flushFireAndForgetRef.current(), AUTOSAVE_INTERVAL_MS); + return () => clearInterval(id); + }, []); - return () => clearInterval(autoSaveInterval); - }, [hasUnsavedChanges, isSaving, handleSave]); + const flushAwaitRef = useRef(flushAwait); + flushAwaitRef.current = flushAwait; useEffect(() => { - const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { - if (nextState === 'background' && hasUnsavedChanges && !isSaving) { - handleSave(true); - } + const sub = AppState.addEventListener('change', (next: AppStateStatus) => { + if (next === 'background') void flushAwaitRef.current(); }); - return () => subscription.remove(); - }, [hasUnsavedChanges, isSaving, handleSave]); + return () => sub.remove(); + }, []); return { isLoading, - isSaving, - handleSave, + decodeError, + markNeedsSave, + flushPending: flushAwait, + setCanvasRef, + canvasRef, }; } diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx index 67dce8bc3..315358f30 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -22,13 +22,11 @@ import Animated, { runOnJS, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { useQueryClient } from '@tanstack/react-query'; import { colors } from '@/theme/tokens'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; import { LoadingScreen } from '@/components/common'; import { - TanstackQueryClient, useGetScrapDetail, useUpdateScrapName, useGetEntireProblemPointing, @@ -37,7 +35,7 @@ import { import { type StudentRootStackParamList } from '@/navigation/student/types'; import { toAlphabetSequence } from '../utils/formatters/toAlphabetSequence'; -import DrawingCanvas, { type DrawingCanvasRef } from '../utils/skia/drawing'; +import DrawingCanvas from '../utils/skia/drawing'; import { ScrapDetailHeader } from '../components/Header/ScrapDetailHeader'; import { TabNavigator } from '../components/scrap/TabNavigator'; import { FilterBar } from '../components/scrap/FilterBar'; @@ -98,7 +96,6 @@ const ScrapDetailScreen = () => { const [_scrapName, setScrapName] = useState(); const scrapName = _scrapName ?? scrapDetail?.name ?? ''; - const queryClient = useQueryClient(); React.useEffect(() => { if (scrapDetail) { @@ -138,9 +135,6 @@ const ScrapDetailScreen = () => { } }; - // Refs - const canvasRef = useRef(null); - // Custom Hooks const drawingState = useDrawingState(); const uiState = useScrapUIState(); @@ -174,26 +168,7 @@ const ScrapDetailScreen = () => { }, [refetchScrapDetail, activeNoteId, scrapId]) ); - useEffect(() => { - return () => { - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions( - 'get', - '/api/student/scrap/{scrapId}/handwriting', - { params: { path: { scrapId } } } - ).queryKey, - }); - }; - }, [scrapId, queryClient]); - - const handwriting = useHandwritingManager({ - scrapId, - canvasRef, - hasUnsavedChanges: drawingState.hasUnsavedChanges, - onSaveSuccess: () => { - drawingState.markAsSaved(); - }, - }); + const handwriting = useHandwritingManager({ scrapId }); // Tab management const [tabLayouts, setTabLayouts] = useState>({}); @@ -209,36 +184,25 @@ const ScrapDetailScreen = () => { } }, [activeNoteId, scrapId, navigation]); - // scrapId 변경 시 모든 state 초기화 + // scrapId 변경 시 screen-scoped state 초기화 (canvas reset 은 매니저가 담당) useEffect(() => { - // 저장 중이면 초기화 지연 (데이터 유실 방지) - if (handwriting.isSaving) { - // 저장이 완료될 때까지 대기 - const checkSaveComplete = setInterval(() => { - if (!handwriting.isSaving) { - clearInterval(checkSaveComplete); - // 저장 완료 후 초기화 - drawingState.reset(); - uiState.reset(); - canvasRef.current?.clear(); - setTabLayouts({}); - } - }, 100); // 100ms마다 체크 - - return () => clearInterval(checkSaveComplete); - } - - // 저장 중이 아니면 즉시 초기화 - // Drawing state 초기화 drawingState.reset(); - // UI state 초기화 uiState.reset(); - // Canvas 초기화 - canvasRef.current?.clear(); - // Tab layouts 초기화 setTabLayouts({}); }, [scrapId]); + // DrawingCanvas onHistoryChange 안정적 reference — 인라인 함수로 전달하면 + // canvas 내부 useCallback deps 가 매 render 마다 변하면서 무한 렌더 발생. + const { setHistoryState } = drawingState; + const { markNeedsSave } = handwriting; + const handleHistoryChange = useCallback( + (canUndo: boolean, canRedo: boolean) => { + setHistoryState(canUndo, canRedo); + markNeedsSave(); + }, + [setHistoryState, markNeedsSave] + ); + // Save indicator animation interval const indicatorTimeoutRef = useRef(null); useEffect(() => { @@ -433,6 +397,20 @@ const ScrapDetailScreen = () => { return ; } + // Decode error state + if (handwriting.decodeError) { + return ( + + {handwriting.decodeError} + navigation.goBack()} + className='rounded bg-gray-300 px-[16px] py-[8px]'> + 뒤로가기 + + + ); + } + // Error state if (!scrapDetail) { return ( @@ -458,8 +436,8 @@ const ScrapDetailScreen = () => { onScrapNameChange={handleUpdateScrapName} showSave={uiState.showSave} onBack={async () => { - const saved = await handwriting.handleSave(true, scrapId); - if (saved) { + const ok = await handwriting.flushPending(); + if (ok) { navigation.goBack(); } }} @@ -476,18 +454,14 @@ const ScrapDetailScreen = () => { activeNoteId={activeNoteId} onTabPress={async (noteId) => { if (noteId === activeNoteId) return; - // 저장 중이면 탭 전환 방지 - if (handwriting.isSaving) return; - const saved = await handwriting.handleSave(true, scrapId); - if (!saved) return; + const ok = await handwriting.flushPending(); + if (!ok) return; setActiveNote(noteId); }} onTabClose={async (noteId) => { if (noteId === activeNoteId) { - // 저장 중이면 탭 닫기 방지 - if (handwriting.isSaving) return; - const saved = await handwriting.handleSave(true, scrapId); - if (!saved) return; + const ok = await handwriting.flushPending(); + if (!ok) return; } closeNote(noteId); }} @@ -615,8 +589,8 @@ const ScrapDetailScreen = () => { canvasRef.current?.undo()} - onRedo={() => canvasRef.current?.redo()} + onUndo={() => handwriting.canvasRef.current?.undo()} + onRedo={() => handwriting.canvasRef.current?.redo()} isEraserMode={drawingState.isEraserMode} isTextMode={drawingState.isTextMode} onPenModePress={drawingState.setPenMode} @@ -635,14 +609,13 @@ const ScrapDetailScreen = () => { isNarrow={isNarrow} /> From 193935e2e928ebc9ecfc1c2c35588f3786bc05a0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 5 May 2026 12:39:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Copilot=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=E2=80=94=20flushPending=20void=20?= =?UTF-8?q?=ED=99=94=20+=20autosave=20=ED=86=A0=EC=8A=A4=ED=8A=B8/retry=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flushPending Promise → Promise (호출부 ok 가드 3곳 제거) - autosave fire-and-forget 실패 시 needsSaveRef 복구 — 다음 interval 에 재시도 - 자동저장 토스트 제거, 명시 flush 토스트만 유지 (이중 토스트 방지) - inline useMutation 제거, useUpdateHandwriting 재사용 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scrap/hooks/useHandwritingManager.ts | 64 +++++-------------- .../scrap/screens/ScrapDetailScreen.tsx | 12 ++-- 2 files changed, 21 insertions(+), 55 deletions(-) diff --git a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts index 94e0b0d13..ebd1b53ee 100644 --- a/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts +++ b/apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts @@ -1,25 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; -import { useGetHandwriting } from '@/apis'; -import { type paths } from '@schema'; +import { useGetHandwriting, useUpdateHandwriting } from '@/apis'; import { showToast } from '../components/Notification/Toast'; import { type DrawingCanvasRef, type Stroke, type TextItem } from '../utils/skia/drawing'; import { decodeHandwritingData, encodeHandwritingData } from '../utils/handwritingEncoder'; -type UpdateHandwritingRequest = - paths['/api/student/scrap/{scrapId}/handwriting']['put']['requestBody']['content']['application/json']; -type UpdateHandwritingResponse = - paths['/api/student/scrap/{scrapId}/handwriting']['put']['responses']['200']['content']['*/*']; - -interface UpdateHandwritingParams { - scrapId: number; - request: UpdateHandwritingRequest; -} - const AUTOSAVE_INTERVAL_MS = 5000; const FLUSH_TIMEOUT_MS = 5000; @@ -68,7 +55,6 @@ export interface UseHandwritingManagerProps { } export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { - const queryClient = useQueryClient(); const { data: handwritingData, isLoading } = useGetHandwriting(scrapId, !!scrapId); // canvas 인스턴스는 ref 로, mount 사실은 boolean state 로 분리. @@ -82,29 +68,7 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { setCanvasMounted(node !== null); }, []); - const updateMutation = useMutation({ - mutationFn: async ({ - scrapId: id, - request, - }: UpdateHandwritingParams): Promise => { - const { data } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { - params: { path: { scrapId: id } }, - body: request, - }); - return data as UpdateHandwritingResponse; - }, - onSuccess: (response, { scrapId: id }) => { - queryClient.setQueryData( - TanstackQueryClient.queryOptions('get', '/api/student/scrap/{scrapId}/handwriting', { - params: { path: { scrapId: id } }, - }).queryKey, - response - ); - }, - onError: () => { - showToast('error', '자동저장에 실패했어요'); - }, - }); + const updateMutation = useUpdateHandwriting(); const needsSaveRef = useRef(false); const pendingLoadRef = useRef(false); @@ -190,10 +154,18 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { if (decision !== 'allow' || !c) return; const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []); needsSaveRef.current = false; - updateMutationRef.current.mutate({ scrapId, request: { data } }); + updateMutationRef.current.mutate( + { scrapId, request: { data } }, + { + // autosave 는 silent — 토스트 없이 다음 interval 에 재시도 + onError: () => { + needsSaveRef.current = true; + }, + } + ); }, [scrapId]); - const flushAwait = useCallback(async (): Promise => { + const flushPending = useCallback(async (): Promise => { const c = canvasRef.current; const decision = evaluateFlush({ needsSave: needsSaveRef.current, @@ -203,7 +175,7 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { currentScrapId: scrapId, hasCanvas: !!c, }); - if (decision !== 'allow' || !c) return true; + if (decision !== 'allow' || !c) return; const data = encodeHandwritingData(c.getStrokes() ?? [], c.getTexts() ?? []); needsSaveRef.current = false; @@ -215,11 +187,9 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { setTimeout(() => reject(new Error('flush-timeout')), FLUSH_TIMEOUT_MS) ), ]); - return true; } catch { needsSaveRef.current = true; showToast('error', '저장에 실패했어요'); - return true; } }, [scrapId]); @@ -231,12 +201,12 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { return () => clearInterval(id); }, []); - const flushAwaitRef = useRef(flushAwait); - flushAwaitRef.current = flushAwait; + const flushPendingRef = useRef(flushPending); + flushPendingRef.current = flushPending; useEffect(() => { const sub = AppState.addEventListener('change', (next: AppStateStatus) => { - if (next === 'background') void flushAwaitRef.current(); + if (next === 'background') void flushPendingRef.current(); }); return () => sub.remove(); }, []); @@ -245,7 +215,7 @@ export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) { isLoading, decodeError, markNeedsSave, - flushPending: flushAwait, + flushPending, setCanvasRef, canvasRef, }; diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx index 315358f30..16772f3f0 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -436,10 +436,8 @@ const ScrapDetailScreen = () => { onScrapNameChange={handleUpdateScrapName} showSave={uiState.showSave} onBack={async () => { - const ok = await handwriting.flushPending(); - if (ok) { - navigation.goBack(); - } + await handwriting.flushPending(); + navigation.goBack(); }} canGoBack={navigation.canGoBack()} onMoveFolderPress={() => { @@ -454,14 +452,12 @@ const ScrapDetailScreen = () => { activeNoteId={activeNoteId} onTabPress={async (noteId) => { if (noteId === activeNoteId) return; - const ok = await handwriting.flushPending(); - if (!ok) return; + await handwriting.flushPending(); setActiveNote(noteId); }} onTabClose={async (noteId) => { if (noteId === activeNoteId) { - const ok = await handwriting.flushPending(); - if (!ok) return; + await handwriting.flushPending(); } closeNote(noteId); }}