/* 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(); /** 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 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); } }