Rueckmeldung beim Tippen in beiden Suchen
CI / pruefen (pull_request) Canceled after 0s

Die Bibliotheksfilterleiste erzeugte das Signal laengst und warf es weg:
`const [, startTransition] = useTransition()`. Waehrend der Suche blieben die
alten Treffer unveraendert stehen — ohne jeden Hinweis, dass gearbeitet wird.

Beide Suchen teilen sich jetzt eine schmale Klammer (SearchPending): die
Filterleiste meldet den Ladezustand, die Trefferliste daneben — eine
Server-Komponente — wird gedaempft und auf aria-busy gesetzt. Die alten
Treffer bleiben sichtbar; sie verschwinden zu lassen liesse die Liste bei
jedem Tastendruck springen. Der Hinweis „sucht …" steht jetzt auch in der
Bibliothek, nicht nur unter /suche.

Sammelverzoegerung in beiden auf 250 ms angeglichen (vorher 350 und 300).

Zwei Vermutungen aus dem Issue haben sich beim Nachmessen NICHT bestaetigt
und bleiben deshalb unangetastet:

Die Facetten kosten nichts. Gemessen an einer eigens angelegten Bibliothek
mit 2000 Eintraegen (Median aus 9 Laeufen): getLibrary 50 ms,
getLibraryFacets 17 ms, beide parallel 61 ms. Ein Wegfall spaerte rund 12 ms
— eine Zwischenspeicherung braechte dafuer das Risiko veralteter Zaehler.

Langsame Bilder blockieren die Trefferanzeige nicht. Mit kuenstlich um
1500 ms verzoegerten /api/image-Antworten stehen die Treffer nach 465 ms im
DOM, 891 ms vor den Bildern; die Rueckfallkacheln zeigen derweil die Titel.
Ein Umbau des Bild-Endpunkts waere also Aufwand ohne Wirkung.

Refs #3
This commit is contained in:
Florian Wilde
2026-09-02 00:23:04 +02:00
parent 605afe556d
commit d04248bec9
5 changed files with 157 additions and 68 deletions
+8 -9
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/**
* Suchfeld mit Typfilter.
@@ -26,6 +27,11 @@ export function SearchField({
const [pending, startTransition] = useTransition();
const first = useRef(true);
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische.
@@ -41,7 +47,7 @@ export function SearchField({
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}, 350);
}, 250);
return () => clearTimeout(timer);
}, [value, filter, pathname, router]);
@@ -68,14 +74,7 @@ export function SearchField({
autoFocus
className="fl-input pl-10 text-base"
/>
{pending && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { clsx } from "clsx";
/**
* Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek.
*
* Beide Filterleisten wissen über `useTransition()` längst, dass eine neue
* Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine
* Server-Komponente und steht als `children` daneben. Diese schmale Klammer
* reicht das eine Bit vom Filter zur Liste durch.
*
* Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge
* dieselbe Identität und taugt als Abhängigkeit eines Effekts.
*/
const PendingContext = createContext(false);
const SetPendingContext = createContext<(value: boolean) => void>(() => {});
export function SearchPendingProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState(false);
return (
<SetPendingContext.Provider value={setPending}>
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
</SetPendingContext.Provider>
);
}
/** Für die Filterleiste: meldet, ob gerade gesucht wird. */
export function useReportSearchPending() {
return useContext(SetPendingContext);
}
/**
* Dämpft die Treffer, solange die neue Antwort unterwegs ist.
*
* Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein
* Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft
* und nicht anklickbar ist aber sichtbar „nicht mehr aktuell".
*/
export function SearchResults({ children }: { children: ReactNode }) {
const pending = useContext(PendingContext);
return (
<div
aria-busy={pending}
className={clsx(
"transition-opacity duration-200 motion-reduce:transition-none",
pending && "pointer-events-none opacity-45",
)}
>
{children}
</div>
);
}
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
export function SearchSpinner({ show }: { show: boolean }) {
if (!show) return null;
return (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
);
}
+22 -10
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/**
* Filterleiste der Bibliothek.
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true);
// Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne
// das Signal blieben die alten Treffer stumm stehen und es sah aus, als
// passiere nichts.
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) {
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
first.current = false;
return;
}
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
@@ -62,14 +71,17 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
return (
<div className="space-y-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<div className="relative">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}>