diff --git a/features/INDEX.md b/features/INDEX.md index 10bcc67..41cfd56 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -93,7 +93,7 @@ | PROJ-75 | DB-Performance-Audit (Query-Index-Nutzung, Fan-out, pgxpool-Tuning) | In Review | [PROJ-75](PROJ-75-db-performance-audit.md) | 2026-08-05 | | PROJ-76 | Mail-HTML-Sanitizing schließt CSS-url()/link/srcset nicht ein (Tracking-Pixel-Umgehung) | In Review | [PROJ-76](PROJ-76-mail-html-sanitizing-luecken.md) | 2026-08-05 | | PROJ-77 | Admin-Tab-Bundle-Optimierung (dynamic import statt 19 statische Imports) | In Review | [PROJ-77](PROJ-77-admin-tabs-dynamic-import.md) | 2026-08-05 | -| PROJ-78 | SearchResultsTable re-rendert bei jedem Tastenanschlag im Suchfeld | Planned | [PROJ-78](PROJ-78-search-results-rerender-perf.md) | 2026-08-05 | +| PROJ-78 | SearchResultsTable re-rendert bei jedem Tastenanschlag im Suchfeld | In Review | [PROJ-78](PROJ-78-search-results-rerender-perf.md) | 2026-08-05 | diff --git a/features/PROJ-78-search-results-rerender-perf.md b/features/PROJ-78-search-results-rerender-perf.md new file mode 100644 index 0000000..c009a96 --- /dev/null +++ b/features/PROJ-78-search-results-rerender-perf.md @@ -0,0 +1,97 @@ +--- +id: PROJ-78 +title: SearchResultsTable re-rendert bei jedem Tastenanschlag im Suchfeld +status: In Review +created: 2026-08-05 +--- + +## Problem + +`SearchResultsTable` re-rendert bei jedem Tastenanschlag im Suchfeld, da +`query` in `SearchPage` liegt und die Tabelle nicht memoisiert ist. Bei +`page_size` 100 sind das 100 Zeilen inkl. `sanitizeSnippet`-Aufruf pro +Tastendruck. + +`React.memo` allein würde hier nichts bringen: `onPageChange={(p) => +doSearch(p)}` ist inline definiert, und `doSearch` selbst ist ein +`useCallback` mit allen Filter-States in den Dependencies — die +Memo-Prüfung würde bei jedem Tastendruck ohnehin fehlschlagen, weil sich +die Callback-Referenz ändert. + +Ein sauberer Fix bräuchte einen Ref-basierten Umbau von `useSearch` (z.B. +`doSearch` per `useRef` stabil halten oder Debounce am Input statt an +jedem State-Update). Das ist eine Architekturänderung des Such-Hooks und +war im Rahmen der reinen Crash-Härtung-Session ausgeschlossen. + +Nur relevant bei großen Ergebnislisten/schwacher Client-Hardware — kein +Crash-Risiko. + +## Lösung (Vorschlag) + +1. `useSearch`: `doSearch` stabil halten, z.B. per `useRef` für die + Filter-State-Werte statt sie als `useCallback`-Dependencies zu führen, + oder +2. Debounce direkt am Such-Input (z.B. 300ms) statt bei jedem Tastendruck + sofort neu zu rendern/suchen — reduziert sowohl Re-Renders als auch + API-Calls. +3. `SearchResultsTable` mit `React.memo` + stabilen Callback-Referenzen + umschließen, sobald 1 oder 2 umgesetzt ist. + +## Implementation Notes + +Umgesetzt wurde eine Kombination aus Vorschlag 1–3, bewusst minimal +gehalten (kein Context, kein Reducer-Umbau): + +**1. Debounce am Suchfeld — `src/components/search/SearchFilterBar.tsx:96-124`** +Der Suchbegriff wird jetzt lokal in der Filterbar gehalten (`localQuery`) +und nur debounced (300 ms) per `setQuery` nach oben propagiert. Ein +`syncedRef` merkt sich den zuletzt zwischen Parent und Input abgeglichenen +Wert, damit externe Änderungen (gespeicherte Suche anwenden) weiterhin ins +Feld durchschlagen, ohne eine Rückkopplungsschleife zu erzeugen. +Wirkung: State-Update in `SearchPage` passiert max. alle 300 ms statt pro +Tastendruck — die gesamte Seite inkl. Tabelle rendert entsprechend seltener. + +**2. `doSearch` referenzstabil — `src/hooks/useSearch.ts:25-71`** +Die Filterwerte werden in `filtersRef` gespiegelt; `doSearch` hat jetzt +leere Dependencies und liest ausschließlich `filtersRef.current`. Damit +ändert sich die Callback-Referenz nie mehr. +`setQuery` ist ein Wrapper (`useSearch.ts:47-50`), der den Wert **synchron** +in den Ref schreibt und erst dann den State setzt — dadurch verwendet ein +unmittelbar folgendes `doSearch(1)` bereits den neuen Suchbegriff. + +**3. `React.memo` — `src/components/search/SearchResultsTable.tsx:65,190`** +Komponente in `SearchResultsTableInner` umbenannt und als +`memo(SearchResultsTableInner)` exportiert. In +`src/app/search/page.tsx:245` wurde das inline `onPageChange={(p) => +doSearch(p)}` durch `onPageChange={doSearch}` ersetzt. +Alle Props der Tabelle (`results`, `selected`, `setSelected`, `total`, +`page`, `totalPages`, `onPageChange`) sind beim Tippen jetzt +referenzstabil → der Memo-Vergleich greift, die 100 Zeilen inkl. +`sanitizeSnippet` werden nicht neu gerendert. + +**Enter-Verhalten bewusst erhalten:** `handleSubmit` in der Filterbar +flusht den Debounce (`setQuery(localQuery)`) vor dem Aufruf von +`onSubmit` — Enter direkt nach dem Tippen sucht weiterhin sofort mit dem +gerade eingegebenen Begriff. + +**Nicht geändert:** Die übrigen Filter (Von/An/Datum/Sortierung/Anhang) +bleiben unverändert direkt-kontrolliert (kein Debounce), Pagination, +gespeicherte Suchen, Export- und Upload-Dialoge, `useSavedSearches` und +der Initial-Load-Effekt in `useSearch`. + +Verifikation: `npm run build` (inkl. TypeScript) erfolgreich. + +## Acceptance Criteria + +- [x] Tippen im Suchfeld löst kein Re-Render der Ergebnistabelle mehr aus, + solange keine neue Suche ausgelöst wurde. + (Doppelt abgesichert: Debounce verhindert das State-Update in + `SearchPage`, `React.memo` + stabile Props fangen den verbleibenden + Fall ab.) +- [x] Bestehendes Verhalten (Suche bei Enter/Debounce-Ende, Pagination) + funktional unverändert — Enter flusht den Debounce vor der Suche. +- [ ] Messbare Verbesserung (z.B. React DevTools Profiler) bei + `page_size` 100. + **Nicht automatisiert durchführbar** — React DevTools Profiler ist im + Agent-Kontext nicht verfügbar. Begründung per Code-Analyse siehe + Implementation Notes; manuelle Messung offen für QA. diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index b612fc1..633fe4a 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -242,7 +242,7 @@ export default function SearchPage() { total={total} page={page} totalPages={totalPages} - onPageChange={(p) => doSearch(p)} + onPageChange={doSearch} /> ) : null} diff --git a/src/components/search/SearchFilterBar.tsx b/src/components/search/SearchFilterBar.tsx index e7cb567..d1fa4da 100644 --- a/src/components/search/SearchFilterBar.tsx +++ b/src/components/search/SearchFilterBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { type SavedSearch } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -88,13 +89,44 @@ export function SearchFilterBar(props: SearchFilterBarProps) { onDeleteSavedSearch, } = props; + // PROJ-78: Der Suchbegriff wird lokal gehalten und nur debounced (300 ms) + // nach oben propagiert. Dadurch loest Tippen keinen Re-Render der Seite + // (und damit der Ergebnistabelle) pro Tastendruck mehr aus. + const [localQuery, setLocalQuery] = useState(query); + // Letzter zwischen Parent und Input abgeglichener Wert. + const syncedRef = useRef(query); + + // Externe Aenderungen (z.B. gespeicherte Suche anwenden) uebernehmen. + useEffect(() => { + if (query === syncedRef.current) return; + syncedRef.current = query; + setLocalQuery(query); + }, [query]); + + // Tippen debounced nach oben schreiben. + useEffect(() => { + if (localQuery === syncedRef.current) return; + const t = setTimeout(() => { + syncedRef.current = localQuery; + setQuery(localQuery); + }, 300); + return () => clearTimeout(t); + }, [localQuery, setQuery]); + + // Enter/„Suchen“ darf nicht auf den Debounce warten — Wert sofort flushen. + function handleSubmit(e: React.FormEvent) { + syncedRef.current = localQuery; + setQuery(localQuery); + onSubmit(e); + } + return ( -
+
setQuery(e.target.value)} + value={localQuery} + onChange={(e) => setLocalQuery(e.target.value)} className="w-full flex-1 sm:w-auto" aria-label="Suchbegriff" /> diff --git a/src/components/search/SearchResultsTable.tsx b/src/components/search/SearchResultsTable.tsx index c321959..c1afb3f 100644 --- a/src/components/search/SearchResultsTable.tsx +++ b/src/components/search/SearchResultsTable.tsx @@ -1,5 +1,6 @@ "use client"; +import { memo } from "react"; import { useRouter } from "next/navigation"; import { type SearchHit, type SearchMatchField } from "@/lib/api"; import { sanitizeSnippet } from "@/lib/sanitize"; @@ -61,7 +62,7 @@ interface SearchResultsTableProps { onPageChange: (page: number) => void; } -export function SearchResultsTable({ +function SearchResultsTableInner({ results, selected, setSelected, @@ -185,3 +186,7 @@ export function SearchResultsTable({ ); } + +// PROJ-78: Tippen im Suchfeld aendert keine dieser Props — der teure +// Tabellen-Render (inkl. sanitizeSnippet pro Zeile) wird uebersprungen. +export const SearchResultsTable = memo(SearchResultsTableInner); diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts index ae2b2e3..9cdf89b 100644 --- a/src/hooks/useSearch.ts +++ b/src/hooks/useSearch.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { searchEmails, type SearchHit } from "@/lib/api"; const DEFAULT_PAGE_SIZE = 25; @@ -8,7 +8,7 @@ const DEFAULT_PAGE_SIZE = 25; type SearchUser = { role: string } | null; export function useSearch(user: SearchUser, pageSize: number) { - const [query, setQuery] = useState(""); + const [query, setQueryState] = useState(""); const [fromFilter, setFromFilter] = useState(""); const [toFilter, setToFilter] = useState(""); const [dateFrom, setDateFrom] = useState(""); @@ -22,20 +22,53 @@ export function useSearch(user: SearchUser, pageSize: number) { const [searching, setSearching] = useState(false); const [searched, setSearched] = useState(false); + // Spiegel der aktuellen Filterwerte. Damit bleibt `doSearch` referenzstabil + // (leere Deps) und `SearchResultsTable` kann per React.memo Re-Renders + // ueberspringen, solange sich Ergebnisse/Auswahl nicht aendern (PROJ-78). + const filtersRef = useRef({ + query, + fromFilter, + toFilter, + dateFrom, + dateTo, + sort, + hasAttachment, + pageSize, + }); + filtersRef.current = { + query, + fromFilter, + toFilter, + dateFrom, + dateTo, + sort, + hasAttachment, + pageSize, + }; + + // Schreibt den Suchbegriff synchron in den Ref, damit ein direkt danach + // ausgeloestes doSearch() (z.B. Enter waehrend eines laufenden Debounce) + // bereits den neuen Wert verwendet. + const setQuery = useCallback((v: string) => { + filtersRef.current.query = v; + setQueryState(v); + }, []); + const doSearch = useCallback( async (p: number) => { + const f = filtersRef.current; setSearching(true); try { const res = await searchEmails({ - q: query || undefined, - from: fromFilter || undefined, - to: toFilter || undefined, - date_from: dateFrom || undefined, - date_to: dateTo || undefined, - sort: sort !== "date_desc" ? sort : undefined, - has_attachment: hasAttachment, + q: f.query || undefined, + from: f.fromFilter || undefined, + to: f.toFilter || undefined, + date_from: f.dateFrom || undefined, + date_to: f.dateTo || undefined, + sort: f.sort !== "date_desc" ? f.sort : undefined, + has_attachment: f.hasAttachment, page: p, - page_size: pageSize, + page_size: f.pageSize, }); setResults(res.hits || []); setTotal(res.total); @@ -48,7 +81,7 @@ export function useSearch(user: SearchUser, pageSize: number) { setSearching(false); } }, - [query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize] + [] ); // Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure