perf(PROJ-78): Suchfeld-Debounce + stabile Callbacks gegen Re-Render-Spam
Jeder Tastendruck im Suchfeld setzte bisher sofort State in SearchPage, was über eine instabile doSearch-Referenz einen vollen Re-Render der Ergebnistabelle auslöste (bei page_size 100: 100 Zeilen inkl. sanitizeSnippet pro Tastendruck). Suchbegriff liegt jetzt lokal in der Filterbar und wird debounced (300ms) nach oben propagiert; doSearch liest Filterwerte aus einem Ref statt sie als Dependencies zu führen und bleibt dadurch referenzstabil; SearchResultsTable ist jetzt memoisiert. Enter-Verhalten bleibt sofort (Debounce wird vor dem Submit geflusht), übrige Filter unverändert direkt-kontrolliert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e59b11743a
commit
16bf38876f
@@ -242,7 +242,7 @@ export default function SearchPage() {
|
||||
total={total}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => doSearch(p)}
|
||||
onPageChange={doSearch}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Volltextsuche..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
className="w-full flex-1 sm:w-auto"
|
||||
aria-label="Suchbegriff"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+44
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user