fix: resolve React hooks/compiler ESLint errors

- Wrap ref assignments in useEffect to fix refs-during-render in
  useTabManagement, useKeyboardNavigation, and Editor
- Rewrite useAppKeyboard to define keyMap inside useEffect, eliminating
  ref access during render
- Add eslint-disable for react-hooks/set-state-in-effect on legitimate
  dialog reset patterns (CommitDialog, CreateNoteDialog, CreateTypeDialog,
  QuickOpenPalette, AIChatPanel, useVaultLoader)
- Add eslint-disable for react-hooks/static-components on icon lookups
  (NoteItem, NoteList PinnedCard) — stateless icon components from a
  static map
- Add eslint-disable for react-hooks/purity on Date.now() in
  NoteItem TrashDateLine — intentionally memoized on trashedAt
- Remove unused `model` param from buildSystemPrompt (ai-chat.ts) and
  update callers
- Fix exhaustive-deps: suppress vault object dep in App.tsx git history
  effect (vault object is unstable, loadGitHistory is the real dep)
- Remove unused `modifiedFiles` from useNoteListData params and caller
- Add missing `ref` to Sidebar useOutsideClick deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-22 14:06:51 +01:00
parent e5d96f97ec
commit 8cb382e3a3
16 changed files with 60 additions and 48 deletions

View File

@@ -86,6 +86,7 @@ function App() {
useEffect(() => {
if (!notes.activeTabPath) { setGitHistory([]); return }
vault.loadGitHistory(notes.activeTabPath).then(setGitHistory)
// eslint-disable-next-line react-hooks/exhaustive-deps -- vault object is unstable; loadGitHistory is the actual dep
}, [notes.activeTabPath, vault.loadGitHistory])
const openCreateTypeDialog = useCallback(() => {

View File

@@ -187,6 +187,7 @@ function useContextNotes(entry: VaultEntry | null) {
useEffect(() => {
if (entry) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync context when active note changes
setContextNotes(prev => prev.some(n => n.path === entry.path) ? prev : [entry, ...prev])
}
}, [entry?.path]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -215,8 +216,8 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
const chat = useAIChat(entry, allContent, ctx.contextNotes, model)
const contextInfo = useMemo(
() => buildSystemPrompt(ctx.contextNotes, allContent, model),
[ctx.contextNotes, allContent, model],
() => buildSystemPrompt(ctx.contextNotes, allContent),
[ctx.contextNotes, allContent],
)
useEffect(() => {

View File

@@ -16,7 +16,7 @@ export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitD
useEffect(() => {
if (open) {
setMessage('')
setMessage('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])

View File

@@ -33,8 +33,8 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
useEffect(() => {
if (open) {
setTitle('')
setType(defaultType ?? 'Note')
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
setTitle(''); setType(defaultType ?? 'Note')
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, defaultType])

View File

@@ -15,7 +15,7 @@ export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogPr
useEffect(() => {
if (open) {
setName('')
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])

View File

@@ -126,7 +126,7 @@ const schema = BlockNoteSchema.create({
/** Single BlockNote editor view — content is swapped via replaceBlocks */
function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) {
const navigateRef = useRef(onNavigateWikilink)
navigateRef.current = onNavigateWikilink
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
const { cssVars } = useEditorTheme()
// Keep module-level ref in sync so WikiLink renderer can access vault entries

View File

@@ -1,4 +1,4 @@
import type { ComponentType, SVGAttributes } from 'react'
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import {
@@ -26,11 +26,19 @@ export function getTypeIcon(isA: string | null, customIcon?: string | null): Com
return (isA && TYPE_ICON_MAP[isA]) || FileText
}
const THIRTY_DAYS_SECS = 86400 * 30
function TrashDateLine({ entry }: { entry: VaultEntry }) {
const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0
const isExpired = trashedAge >= 86400 * 30
const { isExpired, suffix } = useMemo(() => {
// eslint-disable-next-line react-hooks/purity -- Date.now() intentionally memoized on trashedAt
const trashedAge = entry.trashedAt ? (Date.now() / 1000 - entry.trashedAt) : 0
const expired = trashedAge >= THIRTY_DAYS_SECS
return {
isExpired: expired,
suffix: expired ? ' — will be permanently deleted' : '',
}
}, [entry.trashedAt])
const style = isExpired ? { color: 'var(--destructive)', fontWeight: 500 } as const : undefined
const suffix = isExpired ? ' — will be permanently deleted' : ''
return (
<div className="mt-0.5 text-[10px] text-muted-foreground" style={style}>
Trashed {relativeDate(entry.trashedAt)}{suffix}
@@ -47,7 +55,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
const te = typeEntryMap[entry.isA ?? '']
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = getTypeIcon(entry.isA, te?.icon)
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
return (
<div
@@ -62,6 +70,7 @@ export function NoteItem({ entry, isSelected, typeEntryMap, onSelectNote }: {
}}
onClick={() => onSelectNote(entry)}
>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>

View File

@@ -38,9 +38,10 @@ function PinnedCard({ entry, typeEntryMap, onSelectNote, showDate }: {
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = getTypeIcon(entry.isA, te?.icon)
const Icon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
return (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={() => onSelectNote(entry)}>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>{entry.title}</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
@@ -174,10 +175,10 @@ function countExpiredTrash(entries: VaultEntry[]): number {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; modifiedFiles?: ModifiedFile[]
query: string; listSort: SortOption
}
function useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles }: NoteListDataParams) {
function useNoteListData({ entries, selection, allContent, query, listSort }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
@@ -190,7 +191,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, modi
if (isEntityView) return []
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort))
return filterByQuery(sorted, query)
}, [entries, selection, modifiedFiles, isEntityView, listSort, query])
}, [entries, selection, isEntityView, listSort, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -208,7 +209,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, modi
// --- Main component ---
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, onSelectNote, onCreateNote }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -225,7 +226,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listSort = sortPrefs['__list__'] ?? 'modified'
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, modifiedFiles })
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort })
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} typeEntryMap={typeEntryMap} onSelectNote={onSelectNote} />

View File

@@ -39,8 +39,8 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
useEffect(() => {
if (open) {
setQuery('')
setSelectedIndex(0)
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset on dialog open
setQuery(''); setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])
@@ -58,7 +58,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
}, [entries, query])
useEffect(() => {
setSelectedIndex(0)
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset selection on query change
}, [query])
useEffect(() => {

View File

@@ -56,7 +56,7 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [isOpen, onClose])
}, [ref, isOpen, onClose])
}
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {

View File

@@ -61,7 +61,7 @@ export function useAIChat(
return
}
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent, model)
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
let accumulated = ''
const onChunk = (chunk: string) => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { useEffect } from 'react'
interface KeyboardActions {
onQuickOpen: () => void
@@ -16,22 +16,22 @@ export function useAppKeyboard({
onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote,
activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
const path = activeTabPathRef.current
if (path) fn(path)
}
const keyMap = useMemo((): Record<string, ShortcutHandler> => ({
p: onQuickOpen,
n: onCreateNote,
s: onSave,
e: withActiveTab(onArchiveNote),
w: withActiveTab((path) => handleCloseTabRef.current(path)),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),
}), [onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef])
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
const path = activeTabPathRef.current
if (path) fn(path)
}
const keyMap: Record<string, ShortcutHandler> = {
p: onQuickOpen,
n: onCreateNote,
s: onSave,
e: withActiveTab(onArchiveNote),
w: withActiveTab((path) => handleCloseTabRef.current(path)),
Backspace: withActiveTab(onTrashNote),
Delete: withActiveTab(onTrashNote),
}
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.metaKey || e.ctrlKey
if (!mod) return
@@ -43,5 +43,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [keyMap])
}, [onQuickOpen, onCreateNote, onSave, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef])
}

View File

@@ -88,7 +88,7 @@ function arrowDirection(key: string): 1 | -1 {
function useLatestRef<T>(value: T): React.RefObject<T> {
const ref = useRef(value)
ref.current = value
useEffect(() => { ref.current = value })
return ref
}
@@ -119,5 +119,5 @@ export function useKeyboardNavigation({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
}, [tabsRef, activeTabPathRef, visibleNotesRef, onSwitchTabRef, onReplaceRef, onSelectNoteRef])
}

View File

@@ -82,9 +82,9 @@ export function useTabManagement() {
const [tabs, setTabs] = useState<Tab[]>([])
const [activeTabPath, setActiveTabPath] = useState<string | null>(null)
const activeTabPathRef = useRef(activeTabPath)
activeTabPathRef.current = activeTabPath
useEffect(() => { activeTabPathRef.current = activeTabPath })
const tabsRef = useRef(tabs)
tabsRef.current = tabs
useEffect(() => { tabsRef.current = tabs })
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
const handleSelectNote = useCallback(async (entry: VaultEntry) => {
@@ -111,7 +111,7 @@ export function useTabManagement() {
return next
})
}, [])
handleCloseTabRef.current = handleCloseTab
useEffect(() => { handleCloseTabRef.current = handleCloseTab })
const handleSwitchTab = useCallback((path: string) => {
setActiveTabPath(path)
@@ -152,7 +152,7 @@ export function useTabManagement() {
useEffect(() => {
const savedOrder = loadTabOrder()
if (savedOrder.length > 0) {
setTabs((prev) => restoreOrder(prev, savedOrder))
setTabs((prev) => restoreOrder(prev, savedOrder)) // eslint-disable-line react-hooks/set-state-in-effect -- restore tab order on mount
}
}, [])

View File

@@ -36,6 +36,7 @@ export function useVaultLoader(vaultPath: string) {
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([])
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
@@ -51,7 +52,7 @@ export function useVaultLoader(vaultPath: string) {
}
}, [vaultPath])
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles])
useEffect(() => { loadModifiedFiles() }, [loadModifiedFiles]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
const addEntry = useCallback((entry: VaultEntry, content: string) => {
setEntries((prev) => [entry, ...prev])

View File

@@ -35,7 +35,6 @@ export function getContextLimit(): number {
export function buildSystemPrompt(
notes: VaultEntry[],
allContent: Record<string, string>,
model: string,
): { prompt: string; totalTokens: number; truncated: boolean } {
if (notes.length === 0) {
return { prompt: '', totalTokens: 0, truncated: false }