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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('store recall actions', () => {
|
||||
useInbox.setState({
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
recallShownIds: new Set<string>()
|
||||
} as Parameters<typeof useInbox.setState>[0]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user