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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })}>
|
||||
|
||||
Reference in New Issue
Block a user