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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user