Files
Flaro/web/app/src/ansichten.ts
T
ScarriffleandClaude Opus 5 637e95fbb1 Die Verwaltung zeichnete sich endlos neu
Gemessen am laufenden Server: nach dem Scan lief eine Schleife mit zwei
Abrufen je Runde, sichtbar als vier dauernd drehende Spinner.

Der Weg dahin: verfolgen() lief bei jedem Aufbau der Mediathekenzeile, sah
den Zustand finished, meldete das über geaendert(), und geaendert() war
mediathekenNeuLaden — also neu zeichnen. Der Neuaufbau startete verfolgen()
erneut, das wieder finished sah. Dass der Lauf längst vorbei war, machte
keinen Unterschied; der Zustand bleibt im Speicher stehen.

Zwei Dinge dagegen. verfolgen() meldet nur noch, wenn es selbst einen Lauf
angestossen hat — beim Aufbau zeigt es den Stand an und schweigt. Und die
Verwaltung frischt über zaehlerAuffrischen() nur noch die Seitenleiste auf
statt die ganze Seite neu zu zeichnen.

Dazu zwei Befunde aus dem Plan, die sich beim Nachsehen als falsch
erwiesen: die Takte werden sehr wohl abbestellt, dafür gibt es seit je
einen MutationObserver, und das Dashboard tauscht seinen Inhalt erst nach
dem Abruf. Geblieben ist der Spinner in den Listen, der bei jedem
Auffrischen erschien — der kommt jetzt nur noch beim ersten Aufbau.

Dazu der Zwischenspeicher für Ansichten. Der Router hängt sie aus statt
sie zu zerstören; beim Zurückkommen stehen sie sofort wieder da, mitsamt
Bildlauf, offenem Reiter und gesetzten Filtern, und laden leise nach.
Gemessen: null Abrufe und 4 von 4 Kacheln beim Zurückkommen aus der
Verwaltung. Höchstens fünf Ansichten, Verfall nach fünf Minuten,
weggeworfen bei allem, was den Bestand ändert. Detailseite und Verwaltung
sind ausgenommen — beide zeigen Laufendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:11:06 +02:00

132 lines
4.7 KiB
TypeScript

/* Ansichten überleben einen kurzen Wechsel.
*
* **Das Problem.** Der Router warf die Mediathek weg und baute sie beim
* Zurückkommen von Grund auf neu: 458 Kacheln, ein Netzabruf, und die
* Bildlaufposition war hin. Für einen Sprung in die Einstellungen und
* zurück ist das zu viel Aufwand für zu wenig Nutzen.
*
* **Was hier passiert.** Der Knoten einer Ansicht wird beim Verlassen
* ausgehängt statt zerstört. Kommt man zurück, hängt er wieder dort, wo er
* war — mitsamt Bildlauf, offenem Reiter und gesetzten Filtern. Im
* Hintergrund frischt er sich auf, damit nicht stillschweigend Altes
* dasteht.
*
* **Was hier nicht passiert.** Die Detailseite wird nicht behalten. Sie ist
* billig gebaut, und ein veralteter Sehfortschritt darauf wäre ärgerlicher
* als der Neuaufbau. Die Verwaltung auch nicht — sie zeigt Laufendes. */
/** Wie viele Ansichten gleichzeitig vorgehalten werden. */
const HOECHSTENS = 5;
/** Nach dieser Zeit wird neu gebaut statt wiederverwendet. */
const VERFALL_MS = 5 * 60 * 1000;
type Eintrag = {
knoten: HTMLElement;
abbruch: AbortController;
scroll: number;
gebaut: number;
/** Wird beim Wiedereinhängen gerufen — holt im Hintergrund Frisches. */
auffrischen?: () => void;
};
const gemerkt = new Map<string, Eintrag>();
/** Was eine Ansicht beim Zurückkommen nachladen will.
*
* Als `WeakMap` und nicht als Eigenschaft am Knoten: so hängt nichts am DOM,
* und mit dem Knoten verschwindet auch der Eintrag. */
const nachlader = new WeakMap<HTMLElement, () => void>();
/** Meldet an, was beim Wiedereinhängen im Hintergrund geholt werden soll.
*
* **Ohne das sähe man beim Zurückkommen veraltete Daten und merkte es nicht.**
* Die Ansicht steht sofort da — das Frische kommt, wenn es da ist. */
export function beiRueckkehr(knoten: HTMLElement, tun: () => void): void {
nachlader.set(knoten, tun);
}
/** Die Ansicht, die gerade hängt — damit ihr Bildlauf beim Verlassen mitkommt. */
let aktuell: { schluessel: string; eintrag: Eintrag } | null = null;
/** Hängt die laufende Ansicht aus und merkt sich, wo sie stand. */
export function ablegen(): void {
if (!aktuell) return;
aktuell.eintrag.scroll = window.scrollY;
aktuell.eintrag.knoten.remove();
aktuell = null;
}
/** Holt eine Ansicht aus dem Speicher oder baut sie — und hängt sie ein.
*
* `bauen` bekommt ein `AbortSignal`. Jede Ansicht, die einen Takt setzt oder
* nachlädt, prüft es, bevor sie weitermacht: ohne das liefen nach zehn
* Wechseln zehn Takte nebeneinander. */
export function einhaengen(
schluessel: string,
ziel: HTMLElement,
bauen: (signal: AbortSignal) => HTMLElement,
optionen: { merken?: boolean } = {},
): void {
ablegen();
const merken = optionen.merken ?? false;
const vorhanden = merken ? gemerkt.get(schluessel) : undefined;
const frisch = vorhanden && Date.now() - vorhanden.gebaut < VERFALL_MS;
if (vorhanden && !frisch) {
vorhanden.abbruch.abort();
gemerkt.delete(schluessel);
}
const eintrag: Eintrag = frisch ? vorhanden : (() => {
const abbruch = new AbortController();
return { knoten: bauen(abbruch.signal), abbruch, scroll: 0, gebaut: Date.now() };
})();
ziel.appendChild(eintrag.knoten);
if (frisch) {
// **Erst hängen, dann scrollen.** Vor dem Einhängen hat das Dokument die
// Höhe noch nicht, und `scrollTo` landete auf 0.
window.scrollTo(0, eintrag.scroll);
// **Danach nachladen, nicht davor.** Die alte Ansicht steht sofort da,
// das Frische kommt, wenn es da ist. Umgekehrt sähe man wieder einen
// Spinner, und der ganze Aufwand wäre umsonst.
nachlader.get(eintrag.knoten)?.();
} else {
window.scrollTo(0, 0);
}
if (!merken) {
aktuell = { schluessel, eintrag };
return;
}
gemerkt.delete(schluessel);
gemerkt.set(schluessel, eintrag);
aktuell = { schluessel, eintrag };
// Die am längsten nicht benutzte fliegt raus. `Map` hält die Einfügereihen-
// folge, und durch das Löschen oben rutscht jede Benutzung ans Ende.
while (gemerkt.size > HOECHSTENS) {
const aeltester = gemerkt.keys().next().value!;
gemerkt.get(aeltester)!.abbruch.abort();
gemerkt.delete(aeltester);
}
}
/** Wirft gemerkte Ansichten weg — alle, oder die mit diesem Anfang.
*
* Gerufen von allem, was den Bestand ändert: ein fertiger Scan, eine neue
* Mediathek, ein veröffentlichter Titel. **Nicht** von „gesehen" — das ändert
* eine Kachel, und dafür die ganze Ansicht wegzuwerfen wäre Verschwendung. */
export function vergessen(praefix?: string): void {
for (const [schluessel, eintrag] of [...gemerkt]) {
if (praefix && !schluessel.startsWith(praefix)) continue;
if (aktuell?.schluessel === schluessel) continue;
eintrag.abbruch.abort();
gemerkt.delete(schluessel);
}
}