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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 190 additions & 122 deletions apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘 혼자서 하는 일이 너무 많아진거 같은데?
handwritingManager라고 할 수 있나?
지금 보면 canvas ref, react query, app state, encoding, decoding, navigfation flush 등등 하는게 너무 많은데
캔버스 바인딩 하는거랑 persistence 쪽이랑 반갈해서 쪼개버리든가
책임분리하는게 나을듯?

Original file line number Diff line number Diff line change
@@ -1,154 +1,222 @@
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 { useGetHandwriting, useUpdateHandwriting } from '@/apis';

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';

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<DrawingCanvasRef | null>;
hasUnsavedChanges: boolean;
onSaveSuccess?: () => void;
onSaveError?: () => void;
}

export function useHandwritingManager({
scrapId,
canvasRef,
hasUnsavedChanges,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘 아직 useDrawingState에 그대로 남아있는데 안 쓸 거면 싹 다 지워버리지?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

markassaved도

onSaveSuccess,
onSaveError,
}: UseHandwritingManagerProps) {
export function useHandwritingManager({ scrapId }: UseHandwritingManagerProps) {
const { data: handwritingData, isLoading } = useGetHandwriting(scrapId, !!scrapId);
const { mutate: updateHandwriting, isPending: isSaving } = useUpdateHandwriting();
const lastSavedDataRef = useRef<string>('');
const currentScrapIdRef = useRef<number>(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<DrawingCanvasRef | null>(null);
const [canvasMounted, setCanvasMounted] = useState(false);
const setCanvasRef = useCallback((node: DrawingCanvasRef | null) => {
canvasRef.current = node;
setCanvasMounted(node !== null);
}, []);

const updateMutation = useUpdateHandwriting();

const needsSaveRef = useRef(false);
const pendingLoadRef = useRef(false);
const appliedScrapIdRef = useRef<number | null>(null);
const [decodeError, setDecodeError] = useState<string | null>(null);

// decodeError / updateMutation 을 ref 로 미러링 — 콜백 deps 에서 제외해
// 매 render 마다 새 reference 가 만들어지는 걸 막음 (DrawingCanvas 의
// notifyHistoryChange 가 prop 변경에 따라 새 reference 가 되어 캔버스 내부
// useEffect 가 재발화 → onHistoryChange 콜백 호출 → setState → 무한 루프).
const decodeErrorRef = useRef<string | null>(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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이러면 invalidate 돼서 handwritingdata 다시 내려와서 새 값으로 바뀌는 경우는 어떡해?

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(() => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 근데 지금 구현이 기존 inflight mutation을 뭐 따로 wait하거나 cancel하는게 없어보이는데
이러면 save 요청 1 보내고 save 요청 2 또 보냈을 때, 2 먼저 성공하고 이후에 1 성공하면 서버에 저장된게 거꾸로 돌아갈 수도 있음

inflight save 직렬화를 하든 request 별로 sequence나 version 매겨놓고 제일 최신거만 처리하게 하든 해

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 } },
{
// autosave 는 silent — 토스트 없이 다음 interval 에 재시도
onError: () => {
needsSaveRef.current = true;
},
}
);
}, [scrapId]);
Comment on lines +144 to +166
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저장 요청 실패랑 탭전환 or 뒤로가기 겹치면 날아갈거같은데?
그리고 요청 끝나지도 않았는데 needsSaveRef.current - false 해버리면 안될 거 같은데,

만약

  1. 유저가 스크랩1에 필기
  • needsSaveRef.current = true
  1. auto save(5초) trigger
  • flushFireAndForget()
  • needsSaveRef.current = false (요청 아직 들어가지도 않았고, 성공하리라는 보장도 없음)
  • 서버에 저장 요청 mutate
  1. 저장 요청 pending 상태에서
  2. 유저가 탭 전환 혹은 goBack
  3. flushPending() 호출
  4. 그런데 이미 !needsSaveref.current임 → 그냥 return
  5. 이러고 실패해서 onError 타면?
  6. needsSaveRef를 암만 true로 돌려도 저장할 기회가 없음

일단 pending 중인걸 저장 완료랑 같다고 취급하는 것도 문제고, explicit flush(탭 전환이나 goBack)에 pending 안 기다리는 것도 문제인듯

in flight 일 때 현재 진행중인 autosave promise tracing해서 flushpending에서 기다리도록 하고 실패하면 재요청 날리는거 추가하고, 성공 전까지 needsSaveRef를 false로 두지 말든가 해야할듯?

암튼 autosave 요청 보낸거랑 저장 성공한거랑 같다고 보면 안돼


const strokes = canvasRef.current.getStrokes();
const texts = canvasRef.current.getTexts();
const flushPending = useCallback(async (): Promise<void> => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

얘도 마찬가지로 inflight mutation wait이나 cancel이 없어보임

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;

try {
await Promise.race([
updateMutationRef.current.mutateAsync({ scrapId, request: { data } }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('flush-timeout')), FLUSH_TIMEOUT_MS)
),
]);
} catch {
needsSaveRef.current = true;
showToast('error', '저장에 실패했어요');
}
}, [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<boolean>((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 flushPendingRef = useRef(flushPending);
flushPendingRef.current = flushPending;

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 flushPendingRef.current();
});
return () => subscription.remove();
}, [hasUnsavedChanges, isSaving, handleSave]);
return () => sub.remove();
}, []);

return {
isLoading,
isSaving,
handleSave,
decodeError,
markNeedsSave,
flushPending,
setCanvasRef,
canvasRef,
};
}
Loading