Skip to content

[fix/MAT-524] 필기 자동저장 race 로 인한 백엔드 빈 PUT 회귀 fix#312

Closed
b0nsu wants to merge 2 commits into
fix/mat-349-handwriting-api-cache-strategyfrom
fix/mat-524-autosave-data-loss
Closed

[fix/MAT-524] 필기 자동저장 race 로 인한 백엔드 빈 PUT 회귀 fix#312
b0nsu wants to merge 2 commits into
fix/mat-349-handwriting-api-cache-strategyfrom
fix/mat-524-autosave-data-loss

Conversation

@b0nsu
Copy link
Copy Markdown
Collaborator

@b0nsu b0nsu commented May 4, 2026

Summary

handwriting autosave race 봉쇄. 빠른 탭 전환 시 빈 PUT 으로 백엔드 데이터를 덮어쓰던 regression 해결.

Stacked on PR #288 (MAT-349) — base: `fix/mat-349-handwriting-api-cache-strategy`

Linear

Changes

  • 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 없는 문제 우회)

Testing

  • `pnpm typecheck` / `pnpm lint` 통과
  • 시뮬레이터 (수동): 탭 A→B→A 빠른 전환, cold load + 재진입, AppState bg flush, 빈 스크랩

Risk / Impact

  • 영향 범위: ScrapDetailScreen 필기 자동저장
  • 검증: 탭 A→B→A→C→A 빠른 전환 5회 반복 시 A의 그림이 사라지지 않는지
  • 배포 유의: silent flush (저장 진행 중 UI 표시 없음, 실패 시 toast). spec AC-4.2 약화 — 디자이너 컨펌 후 follow-up 가능

Follow-ups

  • MAT-542 — base64 wrapping 제거 (서버 합의 필요)
  • MAT-543 — autosave debounce + stroke count limit
  • useImperativeHandle deps 안정화 — PR [feat/MAT-364] 캔버스 통합 #306 (MAT-364) fold

Screenshots / Video

(필요 시)

@linear
Copy link
Copy Markdown

linear Bot commented May 4, 2026

@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pointer-admin Ready Ready Preview, Comment May 5, 2026 3:40am

@b0nsu b0nsu changed the title [fix/MAT-524] staleTime: Infinity 도입으로 인한 필기 데이터 유실 (autosave regression) [fix/MAT-524] staleTime: Infinity 도입으로 인한 필기 데이터 유실 May 4, 2026
@b0nsu b0nsu changed the base branch from develop to fix/mat-349-handwriting-api-cache-strategy May 4, 2026 15:26
- 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 explicit flushPending() semantics.
  • Removed DrawingCanvas remount-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.

Comment thread apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts Outdated
Comment thread apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts Outdated
Comment thread apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts Outdated
Comment thread apps/native/src/features/student/scrap/hooks/useHandwritingManager.ts Outdated
- 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>
@b0nsu b0nsu changed the title [fix/MAT-524] staleTime: Infinity 도입으로 인한 필기 데이터 유실 [fix/MAT-524] 필기 자동저장 race 로 인한 백엔드 빈 PUT 회귀 fix May 5, 2026
b0nsu added a commit that referenced this pull request May 5, 2026
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();
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.

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();
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.

여기도 flushPending 성공 여부에 따라 setActiveNote를 하든말든 하든가, 아니면 탭이니까 전환은 바로 시켜주고 저장 큐는 살려놓든가

Comment on lines +144 to +166
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]);
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 요청 보낸거랑 저장 성공한거랑 같다고 보면 안돼


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 (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 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이 없어보임

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 쪽이랑 반갈해서 쪼개버리든가
책임분리하는게 나을듯?

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도

@b0nsu
Copy link
Copy Markdown
Collaborator Author

b0nsu commented May 8, 2026

Folded into #313 — 본 PR 의 commits (af8a7d7 MAT-524 race-B 봉쇄, 193935e Copilot 리뷰) 가 #313 branch history 에 그대로 포함되어 있어, #313 의 base 를 fix/mat-349-handwriting-api-cache-strategy 로 변경하여 race fix + dataJson 전환 + save queue 통합 PR 로 정리.

Linear MAT-524 도 #313 으로 라우팅.

@b0nsu b0nsu closed this May 8, 2026
b0nsu added a commit that referenced this pull request May 10, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants