Bilder blockieren nicht mehr die halbe Seite

Warum eine angeklickte Artikelseite nur zur Haelfte lud: Die Bild-Route wird je
Tabellenzeile einmal aufgerufen, und ein Browser haelt je Host nur rund sechs
Verbindungen offen. Was diese Route aufhaelt, legt die ganze Oberflaeche lahm.
Sie hielt gleich dreifach auf:

1. Fehlte die lokale Kopie, holte ``images.ensure`` das Bild MITTEN in der
   Anfrage von der fremden Adresse - bis zu 15 Sekunden, je Zeile, und bei
   jedem Aufruf erneut, weil ein Fehlschlag nirgends vermerkt wurde. Artikel
   aus einem Backup-Import haben genau diese Ausgangslage. Gemessen: 2,01 s
   je Aufruf, dreimal hintereinander. Jetzt: 0,01 s, das Nachholen laeuft nach
   der Antwort im Hintergrund und ein toter Link wird eine Stunde gesperrt.

2. Ein Vorschaubild ist 34x34 Pixel gross - ausgeliefert wurde das Original.
   Gemessen: 2530 KB je Briefmarke, bei 40 Zeilen 99 MB fuer eine Liste. Neu
   erzeugt die Bild-Route (Pillow) eine Vorschau und legt sie daneben ab;
   ``?thumb=1`` liefert sie aus. Gemessen: 0,6 KB.

3. If-None-Match wurde ignoriert. Nach den 5 Minuten Cache-Frist lud der
   Browser jedes Bild komplett neu - das erklaert, warum es "alle 20 Minuten"
   wieder losging. Jetzt 304 ohne Daten.

Dazu im Web: laufende Bildabrufe werden beim Seitenwechsel abgebrochen (sonst
steht die neue Seite hinter Bildern Schlange, die niemand mehr sieht), und wo
``image_version`` bekannt ist, entfaellt die Anfrage fuer bildlose Artikel ganz.

Und der Verbindungsvorrat der Datenbank: 5 (+10) gegen 40 Arbeits-Threads von
FastAPI. Ab der 16. gleichzeitigen Anfrage wartete eine Route stillschweigend
30 Sekunden - von aussen ein haengender Server. Jetzt 10 (+20) mit 10 Sekunden
Frist: lieber ein Fehler als eine halbe Minute Stille.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-18 21:14:43 +02:00
parent 4e2dd3e3eb
commit 8d4954610b
13 changed files with 416 additions and 72 deletions

View File

@@ -105,11 +105,18 @@ function zeitstempel() {
*
* Gibt null zurück, wenn es kein Bild gibt; das ist der Normalfall und kein
* Fehler. Wer die URL nicht mehr braucht, gibt sie mit URL.revokeObjectURL frei.
*
* `signal` bricht den Abruf ab. Das ist bei Bildern kein Feinschliff, sondern
* nötig: Ein Browser hält je Host nur rund sechs Verbindungen offen. Eine Liste
* mit vierzig Vorschaubildern belegt sie alle und wer währenddessen eine Zeile
* anklickt, dessen Artikelseite steht dahinter Schlange, obwohl die Bilder
* niemanden mehr interessieren.
*/
export async function authorizedObjectUrl(path) {
export async function authorizedObjectUrl(path, signal) {
const token = getToken();
const resp = await fetch(`${API_BASE}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal,
});
if (!resp.ok) return null;
return URL.createObjectURL(await resp.blob());

View File

@@ -6,20 +6,33 @@ import Lightbox from "./Lightbox";
/**
* Lädt das Artikelbild aus der eigenen Datenbank.
*
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
*
* ``onLoaded`` meldet, ob ein Bild vorliegt so kann die Artikelseite z.B. den
* „Entfernen"-Knopf ausblenden, wenn es nichts zu entfernen gibt.
*
* Drei Dinge sind hier wichtiger, als sie aussehen jedes einzelne hat schon
* dafür gesorgt, dass eine angeklickte Artikelseite nur halb geladen ankam:
*
* 1. **Abbrechen beim Verlassen.** Ein Browser hält je Host rund sechs
* Verbindungen offen. Wer eine Liste mit vierzig Vorschaubildern verlässt,
* dessen neue Seite steht sonst hinter vierzig Bildern Schlange, die niemand
* mehr sieht.
* 2. **Vorschaubilder statt Originalen.** In der Tabelle sind es 34x34 Pixel;
* das Original ist ein Handy-Foto von mehreren Megabyte.
* 3. **Gar nicht erst fragen.** ``version === null`` heisst „dieser Artikel hat
* nachweislich kein Bild" (aus ``image_version``). Dann bleibt die Anfrage
* weg statt in einem 404 zu enden.
*/
// Modulweiter Cache: Produkt-ID+Version → Objekt-URL (oder null = „kein Bild").
// So werden Thumbnails beim erneuten Öffnen einer Liste nicht jedes Mal neu
// geholt. Die Objekt-URLs bleiben absichtlich bestehen (werden nicht widerrufen),
// bis eine neue Version dieselbe ID ablöst.
// Modulweiter Cache: "Produkt-ID:Art:Version" → Objekt-URL (oder null = „kein
// Bild"). So werden Thumbnails beim erneuten Öffnen einer Liste nicht jedes Mal
// neu geholt. Die Objekt-URLs bleiben absichtlich bestehen (werden nicht
// widerrufen), bis eine neue Version dieselbe ID ablöst.
const bildCache = new Map();
function useBildUrl(productId, version = 0, onLoaded) {
const key = `${productId}:${version}`;
function useBildUrl(productId, version, klein = false, onLoaded = undefined) {
// null = es gibt keins. undefined/0 = wir wissen es nicht und fragen nach.
const bekanntOhneBild = version === null;
const art = klein ? "v" : "g";
const key = `${productId}:${art}:${version || 0}`;
const [url, setUrl] = useState(() => (bildCache.has(key) ? bildCache.get(key) : null));
// Über einen Ref, damit ein bei jedem Render neu erzeugtes onLoaded den Effekt
// nicht erneut auslöst.
@@ -28,6 +41,11 @@ function useBildUrl(productId, version = 0, onLoaded) {
useEffect(() => {
if (!productId) return undefined;
if (bekanntOhneBild) {
onLoadedRef.current?.(false);
setUrl(null);
return undefined;
}
if (bildCache.has(key)) {
const cached = bildCache.get(key);
onLoadedRef.current?.(Boolean(cached));
@@ -35,17 +53,22 @@ function useBildUrl(productId, version = 0, onLoaded) {
return undefined;
}
let abgebrochen = false;
const steuerung = new AbortController();
// ``version`` haengt einen Wert an die Adresse, damit der Browser nach dem
// Speichern nicht seine zwischengespeicherte Fassung ausliefert.
authorizedObjectUrl(`/products/${productId}/image${version ? `?v=${version}` : ""}`)
const frage = [klein ? "thumb=1" : "", version ? `v=${version}` : ""].filter(Boolean);
authorizedObjectUrl(
`/products/${productId}/image${frage.length ? `?${frage.join("&")}` : ""}`,
steuerung.signal,
)
.then((neu) => {
if (abgebrochen) {
if (neu) URL.revokeObjectURL(neu);
return;
}
// Alte Version derselben ID freigeben, dann neu cachen.
const alt = `${productId}:`;
// Alte Version derselben ID und Art freigeben, dann neu cachen.
const alt = `${productId}:${art}:`;
for (const k of bildCache.keys()) {
if (k !== key && k.startsWith(alt)) {
const u = bildCache.get(k);
@@ -62,8 +85,9 @@ function useBildUrl(productId, version = 0, onLoaded) {
});
// Objekt-URL NICHT widerrufen sie bleibt im Cache für den nächsten Mount.
return () => { abgebrochen = true; };
}, [productId, version, key]);
// Der Abruf selbst schon: siehe Punkt 1 oben.
return () => { abgebrochen = true; steuerung.abort(); };
}, [productId, version, key, art, klein, bekanntOhneBild]);
return url;
}
@@ -75,7 +99,7 @@ function useBildUrl(productId, version = 0, onLoaded) {
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
*/
export default function ProduktBild({ productId, alt, className = "", version = 0, onLoaded }) {
const url = useBildUrl(productId, version, onLoaded);
const url = useBildUrl(productId, version, false, onLoaded);
const [zoom, setZoom] = useState(false);
if (!url) return null;
return (
@@ -92,9 +116,12 @@ export default function ProduktBild({ productId, alt, className = "", version =
*
* Hier gibt es sehr wohl einen Platzhalter: In einer Tabellenspalte müssen alle
* Zeilen gleich hoch bleiben, sonst springt die Liste.
*
* ``version`` ist das ``image_version`` des Artikels, wenn die Liste es kennt:
* ``null`` spart die Anfrage ganz, eine Zahl macht die Adresse eindeutig.
*/
export function ProduktThumb({ productId, alt }) {
const url = useBildUrl(productId);
export function ProduktThumb({ productId, alt, version }) {
const url = useBildUrl(productId, version, true);
if (!url) {
return <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>;
}

View File

@@ -75,7 +75,7 @@ export default function Products({ fixedType = null }) {
const columns = [
{ key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
render: (p) => <ProduktThumb productId={p.id} alt={p.name} /> },
render: (p) => <ProduktThumb productId={p.id} version={p.image_version} alt={p.name} /> },
{ key: "name", header: "Name", grow: true, min: 200,
filterText: (p) => p.name, sortValue: (p) => p.name,
render: (p) => (