From 623e4f759a0f8f6021ea9bbd1c49d3a6e8468069 Mon Sep 17 00:00:00 2001 From: David Krcek Date: Wed, 10 Jun 2026 23:47:08 +0200 Subject: [PATCH] fix(search): stop redundant setState cascade in semantic-search effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic-search effect depends on activeNotes and, in the early- return branch, called setSemanticResults([]) (a fresh array each time) plus two more setState calls on every pass. During a large note influx activeNotes changes in rapid batches, re-firing the effect and feeding a re-render cascade that hit 'Maximum update depth exceeded'. Route the resets through resetSemanticState(), which uses functional updaters that return the same reference when already-clear — a no-op in React. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/modals/SearchModal.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/components/modals/SearchModal.tsx b/src/components/modals/SearchModal.tsx index 530a470e..ab3e89de 100644 --- a/src/components/modals/SearchModal.tsx +++ b/src/components/modals/SearchModal.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { MagnifyingGlassIcon, DocumentTextIcon, SparklesIcon } from '@heroicons/react/24/outline' import { useUIStore, useNoteStore, useFolderStore, useWorkspaceStore, useSettingsStore } from '@/stores' import { searchNotes, getMatchSnippet } from '@/utils/search' @@ -53,22 +53,31 @@ export const SearchModal = () => { return searchNotes(activeNotes, fuzzyDebounced).slice(0, 10) }, [activeNotes, fuzzyDebounced]) + // Clear semantic state WITHOUT scheduling a re-render when it's already + // clear. Functional updaters that return the same reference are a no-op in + // React, so this can run on every effect pass (the effect re-fires whenever + // `activeNotes` changes) without feeding an update loop. A plain + // setSemanticResults([]) allocates a fresh array each call, which React + // treats as a change — during a large note influx that cascade tripped + // "Maximum update depth exceeded". + const resetSemanticState = useCallback(() => { + setSemanticResults(prev => (prev.length ? [] : prev)) + setSemanticError(prev => (prev === null ? prev : null)) + setSemanticPending(prev => (prev ? false : prev)) + }, []) + // Run semantic search when in semantic mode + debounced query changes. // Each call: embed the query, cosine-rank against every cached note // embedding, surface the top 10. Falls back to fuzzy results when // the embed call fails (network / quota). useEffect(() => { if (mode !== 'semantic' || !semanticAvailable) { - setSemanticResults([]) - setSemanticError(null) - setSemanticPending(false) + resetSemanticState() return } const query = semanticDebounced.trim() if (!query) { - setSemanticResults([]) - setSemanticError(null) - setSemanticPending(false) + resetSemanticState() return } let cancelled = false @@ -116,7 +125,7 @@ export const SearchModal = () => { } })() return () => { cancelled = true } - }, [mode, semanticDebounced, semanticAvailable, activeNotes]) + }, [mode, semanticDebounced, semanticAvailable, activeNotes, resetSemanticState]) // Recent notes shown when the query box is empty (Obsidian quick-switcher / // VS Code Ctrl+P style). Resolve the MRU note-id list against the ACTIVE