agent-ecosystem/src/renderer/hooks/useDraftPersistence.ts
iliya 2ceed41e00 fix: resolve all CI lint errors and flaky test
- Fix React hooks violations: ref updates during render (useDraftPersistence,
  useChipDraftPersistence, useAttachments), setState in effects across 15+
  components, useCallback self-reference TDZ in useResizableColumns
- Fix TypeScript lint: remove unnecessary type assertions, replace inline
  import() annotations with direct imports, remove unused variables/imports
- Fix SonarJS issues: prefer-regexp-exec, slow-regex in SubagentResolver,
  no-misleading-array-reverse in TeamProvisioningService, use-type-alias
  in ClaudeLogsSection, variable shadowing in ChangeExtractorService
- Fix accessibility: associate labels with controls in filter popovers
- Fix template expression safety: wrap unknown errors with String()
- Fix flaky FileWatcher test: floor instanceCreatedAt to second granularity
  to match filesystem birthtimeMs resolution on Linux
- Replace TODO comments with NOTE where features are intentionally disabled
- Remove unused leadContextByTeam from TeamDetailView store selector

62 files changed across main process, renderer, shared types, and hooks.
All 1646 tests pass, typecheck clean, 0 lint errors.
2026-03-05 21:09:45 +02:00

140 lines
3.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { draftStorage } from '@renderer/services/draftStorage';
interface UseDraftPersistenceOptions {
key: string;
initialValue?: string;
enabled?: boolean;
debounceMs?: number;
}
interface UseDraftPersistenceResult {
value: string;
setValue: (v: string) => void;
isSaved: boolean;
clearDraft: () => void;
}
export function useDraftPersistence({
key,
initialValue,
enabled = true,
debounceMs = 500,
}: UseDraftPersistenceOptions): UseDraftPersistenceResult {
const [value, setValueState] = useState(initialValue ?? '');
const [isSaved, setIsSaved] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingValueRef = useRef<{ key: string; value: string } | null>(null);
const keyRef = useRef(key);
const mountedRef = useRef(true);
useEffect(() => {
keyRef.current = key;
}, [key]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const flushPending = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingValueRef.current != null) {
const pending = pendingValueRef.current;
pendingValueRef.current = null;
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} else {
void draftStorage.saveDraft(pending.key, pending.value);
}
}
}, []);
// Load draft on mount / key change
useEffect(() => {
let cancelled = false;
// Prevent debounced saves for the previous key from landing under the new key.
flushPending();
// Reset local state for the new key immediately. If a draft exists, it will overwrite below.
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional reset on key change before async load
setValueState(initialValue ?? '');
setIsSaved(false);
if (!enabled)
return () => {
cancelled = true;
};
void (async () => {
const draft = await draftStorage.loadDraft(key);
if (cancelled) return;
if (draft != null && initialValue == null) {
setValueState(draft);
setIsSaved(true);
}
})();
return () => {
cancelled = true;
};
}, [key, enabled, initialValue, flushPending]);
// Flush on unmount
useEffect(() => {
return () => {
flushPending();
};
}, [flushPending]);
const setValue = useCallback(
(v: string) => {
setValueState(v);
setIsSaved(false);
if (!enabled) return;
pendingValueRef.current = { key: keyRef.current, value: v };
if (timerRef.current != null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
const pending = pendingValueRef.current;
pendingValueRef.current = null;
if (pending == null) return;
if (pending.value.length === 0) {
void draftStorage.deleteDraft(pending.key);
} else {
void draftStorage.saveDraft(pending.key, pending.value).then(() => {
if (mountedRef.current) setIsSaved(true);
});
}
}, debounceMs);
},
[enabled, debounceMs]
);
const clearDraft = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
pendingValueRef.current = null;
setValueState('');
setIsSaved(false);
if (enabled) {
void draftStorage.deleteDraft(keyRef.current);
}
}, [enabled]);
return { value, setValue, isSaved, clearDraft };
}