[fix/MAT-524] 필기 자동저장 race 로 인한 백엔드 빈 PUT 회귀 fix#312
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- 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) <noreply@anthropic.com>
60076ec to
af8a7d7
Compare
There was a problem hiding this comment.
Pull request overview
This PR addresses a regression where introducing staleTime: Infinity caused a handwriting autosave race, leading to empty PUT requests overwriting existing backend handwriting data during fast tab/scrap switching in ScrapDetailScreen.
Changes:
- Reworked handwriting autosave to guard against pre-load PUTs using screen-scoped refs (
appliedScrapIdRef,pendingLoadRef,needsSaveRef) and explicitflushPending()semantics. - Removed
DrawingCanvasremount-by-key and moved canvas reset/attachment into the handwriting manager via an imperative callback ref. - Added a decode-failure UI state that blocks saving and forces an explicit back navigation.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx | Switches to the new handwriting manager API (flush + canvas ref callback), adds decode error screen, and removes key-based canvas remounting. |
| apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts | Implements race-guarded load/apply, autosave interval, explicit flush with timeout, and local mutation/cache updates for handwriting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- flushPending Promise<boolean> → Promise<void> (호출부 ok 가드 3곳 제거) - autosave fire-and-forget 실패 시 needsSaveRef 복구 — 다음 interval 에 재시도 - 자동저장 토스트 제거, 명시 flush 토스트만 유지 (이중 토스트 방지) - inline useMutation 제거, useUpdateHandwriting 재사용 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5초 autosave 메인 스레드 4단계 직렬화(stringify → encodeURIComponent
→ unescape → btoa)를 1단계(JSON.stringify)로 축소.
handwritingEncoder
- canonicalize: Stroke / Point / TextItem 의 키 순서를 명시 재구성하여
byte-stable JSON 출력 보장 (동일 입력 → 동일 출력).
- decodeHandwritingData: 응답 객체만 받는 단일 시그니처. dataJson 우선,
없으면 data(base64) fallback, 둘 다 없으면 빈값. 옛 strokes-only
배열 형식 호환 유지.
useHandwritingManager
- PUT body 를 { dataJson } 으로 (data 미포함) — 자연 마이그레이션.
- 응답 분기를 decodeHandwritingData(handwritingData) 로 단순화.
Stacked on PR #312 — base: fix/mat-524-autosave-data-loss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| if (saved) { | ||
| navigation.goBack(); | ||
| } | ||
| await handwriting.flushPending(); |
There was a problem hiding this comment.
flushPending에서 save fail나 timeout되면 needsSaveRef를 다시 true로 만들고 토스트 띄우는 걸로 보이는데, 왜 return value 없이(성공/실패와 상관없이) 바로 navigation.goBack을 진행하는지?
이러면 hook이 unmount되거나 scrapId 변경 effect가 needsSaveRef/canvas 초기화하면서 방금 저장 실패한 필기를 다시 저장하지 않고 날려먹을수도?
explicit flush를 할거면 성공 여부를 boolean이나 throw로 호출부쪽에 전달해서 실패 시 화면 전환을 막든가, 화면 전환을 하더라도 scrapId별 pending save queue를 살려두든가 해야할듯
| if (handwriting.isSaving) return; | ||
| const saved = await handwriting.handleSave(true, scrapId); | ||
| if (!saved) return; | ||
| await handwriting.flushPending(); |
There was a problem hiding this comment.
여기도 flushPending 성공 여부에 따라 setActiveNote를 하든말든 하든가, 아니면 탭이니까 전환은 바로 시켜주고 저장 큐는 살려놓든가
| 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 } }, | ||
| { | ||
| // autosave 는 silent — 토스트 없이 다음 interval 에 재시도 | ||
| onError: () => { | ||
| needsSaveRef.current = true; | ||
| }, | ||
| } | ||
| ); | ||
| }, [scrapId]); |
There was a problem hiding this comment.
저장 요청 실패랑 탭전환 or 뒤로가기 겹치면 날아갈거같은데?
그리고 요청 끝나지도 않았는데 needsSaveRef.current - false 해버리면 안될 거 같은데,
만약
- 유저가 스크랩1에 필기
needsSaveRef.current = true
- auto save(5초) trigger
flushFireAndForget()needsSaveRef.current = false(요청 아직 들어가지도 않았고, 성공하리라는 보장도 없음)- 서버에 저장 요청 mutate
- 저장 요청 pending 상태에서
- 유저가 탭 전환 혹은 goBack
flushPending()호출- 그런데 이미
!needsSaveref.current임 → 그냥 return - 이러고 실패해서
onError타면? needsSaveRef를 암만true로 돌려도 저장할 기회가 없음
일단 pending 중인걸 저장 완료랑 같다고 취급하는 것도 문제고, explicit flush(탭 전환이나 goBack)에 pending 안 기다리는 것도 문제인듯
in flight 일 때 현재 진행중인 autosave promise tracing해서 flushpending에서 기다리도록 하고 실패하면 재요청 날리는거 추가하고, 성공 전까지 needsSaveRef를 false로 두지 말든가 해야할듯?
암튼 autosave 요청 보낸거랑 저장 성공한거랑 같다고 보면 안돼
|
|
||
| return () => clearTimeout(loadTimer); | ||
| if (handwritingData === undefined) return; | ||
| if (appliedScrapIdRef.current === scrapId) return; |
There was a problem hiding this comment.
이러면 invalidate 돼서 handwritingdata 다시 내려와서 새 값으로 바뀌는 경우는 어떡해?
| // 이미 저장 중이면 중복 저장 방지 | ||
| if (isSaving) { | ||
| return Promise.resolve(false); | ||
| const flushFireAndForget = useCallback(() => { |
There was a problem hiding this comment.
이거 근데 지금 구현이 기존 inflight mutation을 뭐 따로 wait하거나 cancel하는게 없어보이는데
이러면 save 요청 1 보내고 save 요청 2 또 보냈을 때, 2 먼저 성공하고 이후에 1 성공하면 서버에 저장된게 거꾸로 돌아갈 수도 있음
inflight save 직렬화를 하든 request 별로 sequence나 version 매겨놓고 제일 최신거만 처리하게 하든 해
|
|
||
| const strokes = canvasRef.current.getStrokes(); | ||
| const texts = canvasRef.current.getTexts(); | ||
| const flushPending = useCallback(async (): Promise<void> => { |
There was a problem hiding this comment.
얘도 마찬가지로 inflight mutation wait이나 cancel이 없어보임
There was a problem hiding this comment.
얘 혼자서 하는 일이 너무 많아진거 같은데?
handwritingManager라고 할 수 있나?
지금 보면 canvas ref, react query, app state, encoding, decoding, navigfation flush 등등 하는게 너무 많은데
캔버스 바인딩 하는거랑 persistence 쪽이랑 반갈해서 쪼개버리든가
책임분리하는게 나을듯?
| export function useHandwritingManager({ | ||
| scrapId, | ||
| canvasRef, | ||
| hasUnsavedChanges, |
There was a problem hiding this comment.
얘 아직 useDrawingState에 그대로 남아있는데 안 쓸 거면 싹 다 지워버리지?
PR #312 sterdsterd review + codex 검토 통합. race / silent / lock-in 12건 봉쇄 + 사용자 행동 5종 통제 + dataJson 전환 + 매니저 책임 축소. ## MAT-542 dataJson 전환 - 5초 autosave 4단계 직렬화(stringify → encodeURIComponent → unescape → btoa) → 1단계 JSON.stringify - dataJson: string 필드 사용. data (base64) decoder fallback 유지 - OpenAPI schema sync. 옛 strokes-only 배열 호환 유지 ## 큐 (handwritingSaveQueue.ts) — version 기반 race 봉쇄 - module-level 단일 큐 (hook unmount 무관) - QueueEntry.version (scrapId 별 단조 증가) - enqueueAutosave / flushExplicit (1회 시도, 5s timeout) / setCallbacks 분리 - waiters version 별 Promise map — 동시 호출 race 봉쇄 (R1) - explicit waiter 살아있는 동안 autosave skip — source priority (R2) - success 분기 stale guard 우회 — cleanup dequeue 후도 cache 갱신 (R4) - exponential backoff [1, 2, 4, 8, 16, 30]s, 401/403 hold 10s - 4xx 도 retry 분류 (drop 제거) — 일시 4xx 자동 복구 (R3) ## 매니저 (useHandwritingManager.ts) + flushPending pure helper - race 가드 ref 3개 (appliedScrapIdRef, pendingLoadRef, needsSaveRef) - mutateAsync 직접 호출 제거 → 큐 단일 경로 (D1) - flushPending while 루프 → pure helper (handwritingFlushPending.ts) 추출 (D5) - unmount cleanup dequeue (D8) — 화면 떠난 후 retry 안 함 - discard 시 removeQueries — 다음 진입 시 GET fresh - decode 실패 시 전화면 에러 + PUT 차단 ## 화면 (ScrapDetailScreen.tsx) - navigation.addListener('beforeRemove') — swipe/hardware back 통제 (D7) - handleViewAllPointings 선 flush - HandwritingSaveQueueWiring 마운트 ## Wiring (HandwritingSaveQueueWiring.tsx) - setCallbacks({ onSaved, onAutosaveFailed }) 단일 등록점 (D4) - onSaved → setQueryData (single-device 가정 — invalidate 안 함) - onAutosaveFailed 1초 debounce → showToast('error', '자동 저장에 실패했어요') ## Mutate (putUpdateHandwriting.ts) - response.ok 체크 후 throw — 4xx/5xx silent resolve 차단 - onSuccess setQueryData 제거 — wiring 단일 cache 경로 ## Encoder rename - handwritingEncoder.ts → handwritingDecoder.ts (D6) ## useDrawingState - 사용 안 하던 hasUnsavedChanges / markAsSaved 액션/상태 제거 ## Dev test mode (services/__dev__/handwritingTestMode.ts) - globalThis.setHwTestMode('hold' | 'retry' | 'network_error' | 'slow_2s' | 'slow_6s' | 'normal') - __DEV__ 가드 → production 영향 0 ## 5조건 매핑 (sterdsterd 시선) - Acknowledgement: autosave debounce toast / explicit Alert / success silent - Durability: 큐 retry (화면 안) + 명시 discard. force-kill follow-up - Ordering: version 단조 + 단일 flushLock + version stale guard - Convergence: wiring 단일 setQueryData + discard 시 removeQueries - Visibility: success cache sync 완료 + discard 후 다음 진입 GET fresh ## 사용자 행동 5종 통제 Header ← / 다른 탭 / 탭 × / iOS swipe / Android hardware back / AllPointings 모두 flushPending 거침. 재시도 누르는 동안 navigate 안 됨 (while 루프). ## 테스트 - handwritingSaveQueue.test.ts: 18 cases - handwritingFlushPending.test.ts: 7 cases - jest 25/25, typecheck/lint 통과 - 실기기 spike — globalThis.setHwTestMode(...) 시나리오별 검증 ## Follow-up (별도 PR) - @testing-library/react-native 셋업 + 매니저 hook 통합 테스트 - AsyncStorage 영속성 (force-kill 복구) — 서버 idempotency / multi-device sequence - MAT-543 — autosave debounce + stroke count limit Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
handwriting autosave race 봉쇄. 빠른 탭 전환 시 빈 PUT 으로 백엔드 데이터를 덮어쓰던 regression 해결.
Linear
Changes
Testing
Risk / Impact
Follow-ups
Screenshots / Video
(필요 시)