fix(recall): PR review round 1 — i1 race + m1~m4 + n2 (#6 v0.2.3)

- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
  store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트

skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
      n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 13:38:52 +09:00
parent 348e9ee402
commit 61b6fa6c1f
5 changed files with 18 additions and 13 deletions

View File

@@ -191,8 +191,7 @@ export class CaptureService {
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
const before = this.repo.findById(noteId);
if (!before) throw new Error(`note not found: ${noteId}`);
if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`);
this.repo.markRecallOpened(noteId, new Date().toISOString());
if (this.deps.telemetry) {
await this.deps.telemetry.emit({

View File

@@ -184,6 +184,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
return (
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>

View File

@@ -1,15 +1,19 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';
export function RecallBanner(): React.ReactElement | null {
const candidate = useInbox((s) => s.recallCandidate);
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
const shownIds = useInbox((s) => s.recallShownIds);
const openRecall = useInbox((s) => s.openRecall);
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
const snoozeRecall = useInbox((s) => s.snoozeRecall);
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
const shownIdsRef = useRef<Set<string>>(new Set());
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
const [, setTick] = useState(0);
useEffect(() => {
@@ -20,20 +24,21 @@ export function RecallBanner(): React.ReactElement | null {
return () => clearTimeout(t);
}, [snoozeUntilMs]);
// first-render emit recall_shown (per-session 1회 per note)
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
useEffect(() => {
if (!candidate) return;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
if (shownIds.has(candidate.id)) return;
if (shownIdsRef.current.has(candidate.id)) return;
void inboxApi.emitRecallShown(candidate.id);
useInbox.setState({ recallShownIds: new Set([...shownIds, candidate.id]) });
}, [candidate, snoozeUntilMs, shownIds]);
shownIdsRef.current.add(candidate.id);
}, [candidate, snoozeUntilMs]);
if (candidate === null) return null;
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
const title = candidate.aiTitle ?? candidate.rawText.slice(0, 60);
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
function onOpen() {
void openRecall(candidate!.id);

View File

@@ -20,7 +20,6 @@ interface InboxState {
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
recallShownIds: Set<string>;
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
@@ -63,7 +62,6 @@ export const useInbox = create<InboxState>((set, get) => ({
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
recallShownIds: new Set<string>(),
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
@@ -210,7 +208,8 @@ export const useInbox = create<InboxState>((set, get) => ({
async dismissRecallNote(id) {
await inboxApi.dismissRecall(id);
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
set({ recallCandidate, recallSnoozeUntilMs: null });
},
async snoozeRecall() {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@@ -219,6 +218,8 @@ export const useInbox = create<InboxState>((set, get) => ({
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
const nextKstMidnight = kstMidnightFloor + 86_400_000;
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
await inboxApi.emitRecallSnoozed(candidate.id);

View File

@@ -46,7 +46,6 @@ describe('store recall actions', () => {
useInbox.setState({
recallCandidate: null,
recallSnoozeUntilMs: null,
recallShownIds: new Set<string>()
} as Parameters<typeof useInbox.setState>[0]);
});