feat(quick-261008-who): Vorladen schalten, Grenze acht mit gepinnten Modulen

- Offenhalten-Grenze fuenf auf acht, gepinnte Module nie per LRU verworfen
- Schalter in der App-Leiste der Modulansicht, Karte in Einstellungen -> Eigene Module
- Texte de/en

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 09:17:52 +02:00
parent ac104495b4
commit c7b6a4bc6c
10 changed files with 691 additions and 32 deletions
@@ -43,12 +43,14 @@ describe('useCustomModuleCacheStore', () => {
for (const id of ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX)) activate(id, slot());
// m0 erneut benutzen -> m1 ist jetzt am laengsten unbenutzt.
activate('m0', slot());
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['m0', 'm1', 'm2', 'm3', 'm4']);
expect(useCustomModuleCacheStore.getState().openIds).toEqual(
ids.slice(0, CUSTOM_MODULE_KEEP_ALIVE_MAX),
);
activate('m5', slot());
activate(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX], slot());
const state = useCustomModuleCacheStore.getState();
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
expect(state.recent[0]).toBe('m5');
expect(state.openIds).toEqual(['m0', ...ids.slice(2)]);
expect(state.recent[0]).toBe(ids[CUSTOM_MODULE_KEEP_ALIVE_MAX]);
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
expect(state.modules.m1).toBeDefined();
});
@@ -134,3 +136,94 @@ describe('useCustomModuleCacheStore — preload (quick-261008-who)', () => {
expect(s.active?.id).toBe('a');
});
});
describe('Grenze acht mit gepinnten Modulen (quick-261008-who)', () => {
const mod = (id: string) => ({ id, name: id, url: `https://x.test/${id}` });
const slot = () => document.createElement('div');
const st = () => useCustomModuleCacheStore.getState();
const open = (id: string) => {
st().remember(mod(id));
st().activate(id, slot());
};
beforeEach(() => {
st().clear();
});
it('Grenzwerte: Offenhalten ist acht und deckt die Vorlade-Grenze', () => {
expect(CUSTOM_MODULE_KEEP_ALIVE_MAX).toBe(8);
expect(CUSTOM_MODULE_PRELOAD_MAX).toBeLessThanOrEqual(CUSTOM_MODULE_KEEP_ALIVE_MAX);
});
it('touchLru verwirft ohne Gepinnte das neunte Modul nach LRU', () => {
const recent = ['h', 'g', 'f', 'e', 'd', 'c', 'b', 'a'];
expect(touchLru(recent, 'i', 8)).toEqual({
recent: ['i', ...recent.slice(0, 7)],
evicted: ['a'],
});
});
it('touchLru verwirft mit Gepinnten nur uebrige: 3 gepinnt + 5 uebrige, ein sechstes', () => {
const pinned = ['p1', 'p2', 'p3'];
const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
const result = touchLru(recent, 'o6', 8, pinned);
expect(result.evicted).toEqual(['o1']);
expect(result.recent).toEqual(['o6', 'o5', 'o4', 'o3', 'o2', 'p3', 'p2', 'p1']);
});
it('touchLru: gepinnte Module aktivieren zaehlt nicht unter den uebrigen', () => {
const pinned = ['p1', 'p2', 'p3'];
const recent = ['o5', 'o4', 'o3', 'o2', 'o1', 'p3', 'p2', 'p1'];
expect(touchLru(recent, 'p1', 8, pinned).evicted).toEqual([]);
});
it('acht gepinnt: ein uebriges bleibt als aktives offen (neun), ein weiteres verwirft es', () => {
for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
open('x');
expect(st().openIds).toHaveLength(9);
open('y');
const s = st();
expect(s.openIds).toHaveLength(9);
expect(s.openIds).not.toContain('x');
expect(s.openIds).toContain('y');
for (let i = 0; i < 8; i++) expect(s.pinned).toContain(`p${i}`);
});
it('neu gepinnt verdraengt ueberzaehlige uebrige, nie das aktive', () => {
for (const id of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) open(id);
st().preload(mod('p'));
const s = st();
expect(s.openIds).not.toContain('a');
expect(s.openIds).toContain('h');
expect(s.openIds).toHaveLength(8);
expect(s.openIds).toContain('p');
});
it('activate eines gepinnten Moduls laesst es gepinnt', () => {
st().preload(mod('a'));
st().activate('a', slot());
expect(st().pinned).toEqual(['a']);
});
it('setPinned(false) macht das Modul wieder verwerfbar, ohne den Rahmen zu schliessen', () => {
st().preload(mod('a'));
st().setPinned('a', false);
expect(st().pinned).toEqual([]);
expect(st().openIds).toEqual(['a']);
expect(st().recent).toContain('a');
// beim naechsten Aktivieren gilt die Grenze: a ist das aelteste
for (const id of ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']) open(id);
expect(st().openIds).not.toContain('a');
});
it('setPinned(true) nur fuer offene Module und bis zur Grenze; Reihenfolge bleibt stabil', () => {
st().setPinned('unbekannt', true);
expect(st().pinned).toEqual([]);
for (let i = 0; i < 8; i++) st().preload(mod(`p${i}`));
open('x');
const before = st().openIds;
st().setPinned('x', true);
expect(st().pinned).not.toContain('x');
expect(st().openIds).toEqual(before);
});
});
@@ -17,15 +17,26 @@ import type { CustomModule } from '@/lib/custom-modules-api';
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
* `recent` (zuletzt benutzt vorn).
*
* Vorladen/Pinnen (quick-261008-who): `pinned` sind die Module, die der
* Benutzer beim Start vorladen laesst. Sie zaehlen zu den offenen Rahmen, werden
* aber NIE per LRU verworfen; die uebrigen Module teilen sich den Rest bis
* `CUSTOM_MODULE_KEEP_ALIVE_MAX` (mindestens das gerade geoeffnete). Verhalten
* bei mehr als acht: Vorladen ist serverseitig auf acht begrenzt (HTTP 409 mit
* Hinweis in der Oberflaeche), der Speicher ignoriert weitere Pins. Extremfall:
* acht gepinnt plus ein gerade geoeffnetes, nicht gepinntes Modul = neun Rahmen;
* oeffnet man ein weiteres nicht gepinntes, faellt das vorige heraus.
*/
/**
* Hoechstens so viele eigene Module halten ihren <iframe> offen. Jede offene
* Fremdseite belegt weiter Speicher und laeuft im Hintergrund mit (Skripte,
* Abfragen); fuenf deckt den ueblichen Wechsel zwischen wenigen Werkzeugen ab,
* ohne den Browser bei vielen Eintraegen in der Seitenleiste zu belasten.
* Abfragen). Acht (Nutzerentscheidung 08.10.2026, vorher fuenf) deckt den
* Wechsel zwischen den taeglichen Werkzeugen ab und passt zur Hoechstzahl der
* vorgeladenen Module (`CUSTOM_MODULE_PRELOAD_MAX`), ohne den Browser bei vielen
* Eintraegen in der Seitenleiste zu belasten.
*/
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 8;
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
@@ -36,11 +47,48 @@ export interface LruResult {
evicted: string[];
}
/** Reine LRU-Regel: `id` nach vorn, alles jenseits von `max` wird verworfen. */
export function touchLru(recent: readonly string[], id: string, max: number): LruResult {
/**
* Gemeinsame Begrenzung: gepinnte Kennungen bleiben immer; von den uebrigen
* bleiben hoechstens `max - Anzahl gepinnter` (mindestens eine), in der
* Reihenfolge von `order`. `keepId` wird zuerst behandelt und nie verworfen.
*/
function trimUnpinned(
order: readonly string[],
keepId: string | null,
max: number,
pinned: readonly string[],
): LruResult {
const budget = Math.max(1, max - pinned.length);
const sorted =
keepId && order.includes(keepId) ? [keepId, ...order.filter((o) => o !== keepId)] : order;
const kept = new Set<string>();
const evicted: string[] = [];
let used = 0;
for (const other of sorted) {
if (pinned.includes(other)) {
kept.add(other);
} else if (used < budget) {
used++;
kept.add(other);
} else {
evicted.push(other);
}
}
return { recent: order.filter((o) => kept.has(o)), evicted };
}
/**
* Reine LRU-Regel: `id` nach vorn, alles jenseits der Grenze wird verworfen —
* gepinnte (`pinned`) nie, `id` selbst nie.
*/
export function touchLru(
recent: readonly string[],
id: string,
max: number,
pinned: readonly string[] = [],
): LruResult {
const next = [id, ...recent.filter((other) => other !== id)];
const limit = Math.max(1, max);
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
return trimUnpinned(next, id, max, pinned);
}
export interface ActiveCustomModuleSlot {
@@ -69,6 +117,12 @@ interface CustomModuleCacheState {
* Mehrfachaufruf und Aufrufe jenseits von `CUSTOM_MODULE_PRELOAD_MAX` bewirken nichts.
*/
preload: (mod: CachedCustomModule) => void;
/**
* Vorladen fuer ein schon bekanntes Modul ein- oder ausschalten, ohne den
* Rahmen zu schliessen. Ein: nur fuer offene Module und bis zur Grenze
* `CUSTOM_MODULE_PRELOAD_MAX`. Aus: das Modul wird wieder verwerfbar.
*/
setPinned: (id: string, on: boolean) => void;
/** Die Modulseite zeigt `id` im Platzhalter `slot`: offen halten und nach vorn. */
activate: (id: string, slot: HTMLElement) => void;
/** Die Modulseite verschwindet: <iframe> nur verstecken, nicht verwerfen. */
@@ -104,16 +158,52 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
set((s) => {
if (s.pinned.includes(mod.id)) return s;
if (s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
const pinned = [...s.pinned, mod.id];
// Ein neu gepinntes Modul verkleinert den Platz der uebrigen: ueberzaehlige
// (nie das aktive) fallen heraus.
const { recent, evicted } = trimUnpinned(
s.recent,
s.active?.id ?? null,
CUSTOM_MODULE_KEEP_ALIVE_MAX,
pinned,
);
const openIds = s.openIds.filter((other) => !evicted.includes(other));
return {
modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } },
openIds: s.openIds.includes(mod.id) ? s.openIds : [...s.openIds, mod.id],
pinned: [...s.pinned, mod.id],
openIds: openIds.includes(mod.id) ? openIds : [...openIds, mod.id],
recent,
pinned,
};
}),
setPinned: (id, on) =>
set((s) => {
const isPinned = s.pinned.includes(id);
if (on === isPinned) return s;
if (!on) {
// Wieder verwerfbar: als am laengsten unbenutzt in die LRU-Reihenfolge.
return {
pinned: s.pinned.filter((other) => other !== id),
recent: s.recent.includes(id) ? s.recent : [...s.recent, id],
};
}
if (!s.openIds.includes(id) || s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
const pinned = [...s.pinned, id];
const { recent, evicted } = trimUnpinned(
s.recent,
s.active?.id ?? null,
CUSTOM_MODULE_KEEP_ALIVE_MAX,
pinned,
);
return {
pinned,
recent,
openIds: s.openIds.filter((other) => !evicted.includes(other)),
};
}),
activate: (id, slot) =>
set((s) => {
if (!s.modules[id]) return s;
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX, s.pinned);
const kept = s.openIds.filter((other) => !evicted.includes(other));
return {
recent,