diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 5b36ee9075b..1b2523bb3d8 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -54,10 +54,11 @@ export default function MonacoEditor({ // cleanup and overwrite the correct value with a stale one. const scrollThrottleTimerRef = useRef | null>(null) const propsRef = useRef({ relativePath, language, onSave }) - - useEffect(() => { - propsRef.current = { relativePath, language, onSave } - }, [relativePath, language, onSave]) + // Why: assigning during render keeps the ref current before any event handler + // or effect reads it, avoiding the one-render stale window that a useEffect + // would introduce. Refs are mutable and don't trigger re-renders, so this is + // safe to do unconditionally every render. + propsRef.current = { relativePath, language, onSave } const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 8b6745a24ef..a67ea3ea54a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -72,18 +72,13 @@ export default function RichMarkdownEditor({ const [isEditingLink, setIsEditingLink] = useState(false) const isEditingLinkRef = useRef(false) - useEffect(() => { - onContentChangeRef.current = onContentChange - }, [onContentChange]) - useEffect(() => { - onDirtyStateHintRef.current = onDirtyStateHint - }, [onDirtyStateHint]) - useEffect(() => { - onSaveRef.current = onSave - }, [onSave]) - useEffect(() => { - isEditingLinkRef.current = isEditingLink - }, [isEditingLink]) + // Why: assigning callback refs during render keeps them current before any + // ProseMirror handler reads them, avoiding the one-render stale window that + // useEffect would introduce. Refs are mutable and never trigger re-renders. + onContentChangeRef.current = onContentChange + onDirtyStateHintRef.current = onDirtyStateHint + onSaveRef.current = onSave + isEditingLinkRef.current = isEditingLink const flushPendingSerialization = useCallback(() => { if (serializeTimerRef.current === null) { diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 23a75726dd2..11427ede8dd 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -54,14 +54,19 @@ export default function ChecksPanel(): React.JSX.Element { // remount on worktree switch (that caused an IPC storm on Windows). // Reset worktree-specific local state so stale UI from the previous // worktree doesn't leak (e.g. mid-edit title, stale loading indicators). - useEffect(() => { + // Done during render (not useEffect) so the reset takes effect on the same + // paint as the worktree change — useEffect would leave one render with the + // previous worktree's stale title/loading state visible. + const [prevActiveWorktreeId, setPrevActiveWorktreeId] = useState(activeWorktreeId) + if (activeWorktreeId !== prevActiveWorktreeId) { + setPrevActiveWorktreeId(activeWorktreeId) setEditingTitle(false) setTitleDraft('') setTitleSaving(false) setIsRefreshing(false) setEmptyRefreshing(false) conflictSummaryRefreshKeyRef.current = null - }, [activeWorktreeId]) + } // Find active worktree and repo const { worktree, repo } = useMemo(() => { diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index dbed096e480..b1ad300f1b6 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -1,3 +1,6 @@ +/* eslint-disable max-lines -- Why: these small settings form primitives and controls +co-locate shared layout and keyboard interaction logic, which keeps the settings +panel wiring simple even though the file exceeds the default line limit. */ import { useEffect, useId, useMemo, useRef, useState } from 'react' import { ScrollArea } from '../ui/scroll-area' import { Input } from '../ui/input' @@ -253,15 +256,27 @@ export function FontAutocomplete({ return normalizedQuery ? [...startsWith, ...includes] : suggestions }, [suggestions, normalizedQuery]) - useEffect(() => { + // Why: sync the highlighted index during render rather than via useEffect so + // the correct item is highlighted on the very first paint after open/filter + // changes — useEffect would leave one render with the stale index visible. + const [prevFilteredSuggestions, setPrevFilteredSuggestions] = useState(filteredSuggestions) + const [prevOpen, setPrevOpen] = useState(open) + const [prevHighlightedValue, setPrevHighlightedValue] = useState(value) + if ( + filteredSuggestions !== prevFilteredSuggestions || + open !== prevOpen || + value !== prevHighlightedValue + ) { + setPrevFilteredSuggestions(filteredSuggestions) + setPrevOpen(open) + setPrevHighlightedValue(value) if (!open || filteredSuggestions.length === 0) { setHighlightedIndex(-1) - return + } else { + const selectedIndex = filteredSuggestions.findIndex((font) => font === value) + setHighlightedIndex(Math.max(selectedIndex, 0)) } - - const selectedIndex = filteredSuggestions.findIndex((font) => font === value) - setHighlightedIndex(Math.max(selectedIndex, 0)) - }, [filteredSuggestions, open, value]) + } useEffect(() => { if (!open || highlightedIndex < 0) { diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index bbc632612dd..01b2ff65868 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -23,10 +23,22 @@ export function SshPassphraseDialog(): React.JSX.Element | null { const open = request !== null const requestId = request?.requestId - useEffect(() => { + + // Why: reset form state during render (not useEffect) so the cleared input is + // visible on the same paint as the new request arriving — useEffect would + // leave one render showing the previous passphrase value. + const [prevRequestId, setPrevRequestId] = useState(requestId) + if (requestId !== prevRequestId) { + setPrevRequestId(requestId) if (requestId) { setValue('') setSubmitting(false) + } + } + + // DOM focus is a side effect that must remain in useEffect. + useEffect(() => { + if (requestId) { requestAnimationFrame(() => inputRef.current?.focus()) } }, [requestId]) @@ -109,7 +121,12 @@ export function SshPassphraseDialog(): React.JSX.Element | null { /> -