fix(study): ㄱ→ㅏ hang 다중 안전망 — window pointerup + inflight plain + dbg DEV gate

이전 commit (7f3955c) 의 element-level pointerleave 안전망이 부족 — 펜이 캔버스
영역 *안*에서 hover 해제되면 pointerleave 미발화 (pointerout 만), 캔버스 element
의 setPointerCapture 가 silently 풀린 케이스도 캔버스 element 핸들러로 못 잡음.
isDrawing 락이 영구 → 다음 stroke 진입 거부 → ㄱ→ㅏ 회귀 잔존.

A. window 레벨 pointerup/pointercancel 안전망 (핵심)
  - window.addEventListener('pointerup'|'pointercancel', onWindowPointerEnd).
  - onWindowPointerEnd 가 isDrawing && pointerId == activePointerId 시 endStroke 호출.
  - 캔버스 element 의 capture 가 풀려도 window 에는 거의 항상 도달 → 락 영구 해제.

B. inflight 를 $state 에서 plain 변수로
  - Svelte 5 deep proxy 가 매 pointermove 의 coalesced push 마다 reactive notify.
    60Hz × 8~12 coalesced = 480회/초 의 reactive trigger 가 onPointerMove 핸들러
    실행 시간을 누적시켜 native event queue 적체 → capture race 가능성 증가.
  - UI 는 redraw 함수가 호출 시점에 inflight 직접 read 하므로 reactive 불필요.
  - dbgInflightPts $derived 제거, 패널은 inline `inflight?.points.length` 사용.

C. dbg state mutation DEV 게이트
  - DBG = import.meta.env.DEV 상수. 모든 dbg = ... 호출을 if (DBG) 로 감쌈.
  - prod 빌드에서 Vite 가 if (false) ... 를 DCE → mutation 비용 0.
  - pointerleave 의 capture 활성 가드는 DBG 와 무관하게 항상 적용 (실제 안전망 로직).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-27 12:31:24 +09:00
parent a428b2e679
commit 3cb065c7e3
@@ -52,14 +52,20 @@
let strokes = $state<Stroke[]>(initialStrokes?.strokes ?? []);
let undoStack = $state<Stroke[]>([]);
let inflight: Stroke | null = $state(null);
// inflight 는 plain 변수 — Svelte 5 deep proxy 가 매 pointermove 의 coalesced
// push 마다 reactive notify 하면서 onPointerMove 핸들러 실행 시간 누적 → 다음
// pointermove 가 native queue 에 적체 → 빠른 stroke 시 capture race 가능성.
// UI 는 redraw 함수가 호출 시점에 inflight 를 직접 읽으므로 reactive 불필요.
let inflight: Stroke | null = null;
// 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리.
// $state 로 — 디버그 패널 표시하기 위해. closure 동작은 동일.
// 입력 상태머신 — 다음 stroke 가 막히지 않게 명확히 관리. $state 유지 (디버그
// 패널 표시 + 새 stroke 진입 가드 readability).
let isDrawing = $state(false);
let activePointerId = $state<number | null>(null);
// ── 디버그 카운터 — 사용자가 어디서 누락 발생하는지 직접 보도록 페이지에 라이브 표시.
// ── 디버그 카운터 — DEV 빌드 한정. prod 에선 mutation 자체가 DCE 되도록 모든
// dbg = ... 호출을 if (DBG) 로 감쌌음.
const DBG = import.meta.env.DEV;
let dbg = $state({
down: 0, move: 0, up: 0, cancel: 0, leave: 0,
rejectedByType: 0, rejectedByPointerId: 0,
@@ -67,8 +73,6 @@
lastType: '-',
lastPressure: 0,
});
// 마지막 stroke 의 점 개수 (inflight 또는 직전 finalize 한 stroke)
let dbgInflightPts = $derived(inflight?.points.length ?? 0);
let strokeColor = $state('#e4e4e7');
let tool = $state<Tool>('pen');
@@ -287,10 +291,10 @@
}
function onPointerDown(e: PointerEvent) {
dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure };
if (DBG) dbg = { ...dbg, down: dbg.down + 1, lastType: e.pointerType, lastPressure: e.pressure };
if (!canvas) return;
if (!isPenLike(e)) {
dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
return;
}
// 이미 다른 pointer 가 그리는 중이면 무시 — palm rejection / multi-touch race 차단.
@@ -328,14 +332,14 @@
}
function onPointerMove(e: PointerEvent) {
dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure };
if (DBG) dbg = { ...dbg, move: dbg.move + 1, lastType: e.pointerType, lastPressure: e.pressure };
if (!isDrawing) return;
if (!isPenLike(e)) {
dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
if (DBG) dbg = { ...dbg, rejectedByType: dbg.rejectedByType + 1 };
return;
}
if (e.pointerId !== activePointerId) {
dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
return;
}
@@ -358,7 +362,7 @@
// coalesced events: 빠른 필기에서 보간 점들 모두 반영. 추가로 점 사이 거리가
// 8px 초과 시 자동 중간 점 보간 → 점선 방지.
const coalesced = (e.getCoalescedEvents?.() ?? []) as PointerEvent[];
dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length };
if (DBG) dbg = { ...dbg, coalesced: dbg.coalesced + coalesced.length };
if (coalesced.length > 0) {
for (const ev of coalesced) {
const [cx, cy] = getLocalXY(ev);
@@ -378,17 +382,17 @@
// pointerup 이 캔버스에 routing 안 된 케이스 → 이 분기에서 강제 finalize 해야
// isDrawing 락이 풀려서 다음 stroke 가 막히지 않는다 (ㄱ → ㅏ hang 회귀 방어).
function endStroke(e: PointerEvent) {
if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 };
else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 };
else if (e.type === 'pointerleave') {
// capture 가 활성이면 leave 는 정상 흐름이 아님 — ignore (정상적으로 pointerup
// 이 곧 도착할 것). capture 가 풀렸을 때만 안전망으로 finalize 진행.
if (canvas?.hasPointerCapture?.(e.pointerId)) return;
dbg = { ...dbg, leave: dbg.leave + 1 };
if (DBG) {
if (e.type === 'pointerup') dbg = { ...dbg, up: dbg.up + 1 };
else if (e.type === 'pointercancel') dbg = { ...dbg, cancel: dbg.cancel + 1 };
else if (e.type === 'pointerleave') dbg = { ...dbg, leave: dbg.leave + 1 };
}
// pointerleave 의 capture 활성 가드는 DBG 와 무관하게 적용.
if (e.type === 'pointerleave' && canvas?.hasPointerCapture?.(e.pointerId)) return;
if (!isDrawing) return;
if (e.pointerId !== activePointerId) {
dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
if (DBG) dbg = { ...dbg, rejectedByPointerId: dbg.rejectedByPointerId + 1 };
return;
}
@@ -499,6 +503,16 @@
let resizeObserver: ResizeObserver | null = null;
function onWindowResize() { resizeCanvas(); }
// window 레벨 안전망 — iOS Safari 가 setPointerCapture 를 silently 풀어
// pointerup 이 캔버스 element 에 routing 안 되는 케이스 (특히 펜이 캔버스 영역
// *안*에서 hover 해제될 때 pointerleave 도 미발화) 를 cover. window 에는
// 거의 항상 도달하므로 isDrawing 락이 영구 풀린다 → ㄱ→ㅏ hang 회귀 차단.
function onWindowPointerEnd(e: PointerEvent) {
if (!isDrawing) return;
if (e.pointerId !== activePointerId) return;
endStroke(e);
}
onMount(() => {
try {
const txt = getComputedStyle(document.documentElement).getPropertyValue('--text').trim();
@@ -515,6 +529,8 @@
window.addEventListener('resize', onWindowResize);
window.addEventListener('orientationchange', onWindowResize);
window.addEventListener('beforeunload', onBeforeUnload);
window.addEventListener('pointerup', onWindowPointerEnd);
window.addEventListener('pointercancel', onWindowPointerEnd);
});
onDestroy(() => {
flushSave();
@@ -523,6 +539,8 @@
window.removeEventListener('resize', onWindowResize);
window.removeEventListener('orientationchange', onWindowResize);
window.removeEventListener('beforeunload', onBeforeUnload);
window.removeEventListener('pointerup', onWindowPointerEnd);
window.removeEventListener('pointercancel', onWindowPointerEnd);
}
});
@@ -641,7 +659,7 @@
type:{dbg.lastType} p:{dbg.lastPressure.toFixed(2)}<br/>
down:{dbg.down} move:{dbg.move} up:{dbg.up} cancel:{dbg.cancel}<br/>
rejType:{dbg.rejectedByType} rejId:{dbg.rejectedByPointerId} coal:{dbg.coalesced}<br/>
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{dbgInflightPts}<br/>
drawing:{isDrawing ? 'Y' : 'N'} actId:{activePointerId ?? '-'} infPts:{inflight?.points.length ?? 0}<br/>
strokes:{strokes.length}
</div>
{/if}