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