feat(custom-modules): besuchte eigene Module offen halten (Keep-Alive, hoechstens 5)
iframes liegen dauerhaft in einem Behaelter im Portal-Rahmen und werden per position: fixed deckungsgleich ueber den Platzhalter der Modulseite gelegt; beim Wegnavigieren nur versteckt (visibility), nie neu eingehaengt. Name/Adresse je Modul im Sitzungsspeicher, Nachladen im Hintergrund, 404 verwirft. Abmelden und Loeschen leeren. Im Browser nachgewiesen: gleiches iframe-Element und kein neuer Seitenabruf nach Dashboard -> Modul B -> Modul A; folgt Seitenleiste ein-/ausgeklappt und Handybreite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CUSTOM_MODULE_KEEP_ALIVE_MAX,
|
||||
touchLru,
|
||||
useCustomModuleCacheStore,
|
||||
} from './custom-module-cache-store';
|
||||
|
||||
describe('touchLru', () => {
|
||||
it('setzt ein neues Modul nach vorn ohne zu verwerfen', () => {
|
||||
expect(touchLru(['a', 'b'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
|
||||
});
|
||||
|
||||
it('holt ein vorhandenes Modul nach vorn, ohne es zu verdoppeln', () => {
|
||||
expect(touchLru(['a', 'b', 'c'], 'c', 5)).toEqual({ recent: ['c', 'a', 'b'], evicted: [] });
|
||||
});
|
||||
|
||||
it('verwirft ueber der Grenze das am laengsten unbenutzte', () => {
|
||||
expect(touchLru(['e', 'd', 'c', 'b', 'a'], 'f', 5)).toEqual({
|
||||
recent: ['f', 'e', 'd', 'c', 'b'],
|
||||
evicted: ['a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('haelt das gerade benutzte Modul auch bei unsinniger Grenze', () => {
|
||||
expect(touchLru(['a'], 'b', 0)).toEqual({ recent: ['b'], evicted: ['a'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCustomModuleCacheStore', () => {
|
||||
const slot = () => document.createElement('div');
|
||||
const remember = (id: string) =>
|
||||
useCustomModuleCacheStore.getState().remember({ id, name: id, url: `https://x.test/${id}` });
|
||||
|
||||
beforeEach(() => {
|
||||
useCustomModuleCacheStore.getState().clear();
|
||||
});
|
||||
|
||||
it('haelt die Einhaengereihenfolge stabil und verwirft nach LRU', () => {
|
||||
const ids = Array.from({ length: CUSTOM_MODULE_KEEP_ALIVE_MAX + 1 }, (_, i) => `m${i}`);
|
||||
for (const id of ids) remember(id);
|
||||
const { activate } = useCustomModuleCacheStore.getState();
|
||||
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']);
|
||||
|
||||
activate('m5', slot());
|
||||
const state = useCustomModuleCacheStore.getState();
|
||||
expect(state.openIds).toEqual(['m0', 'm2', 'm3', 'm4', 'm5']);
|
||||
expect(state.recent[0]).toBe('m5');
|
||||
// Metadaten bleiben — nur der offene Rahmen wird verworfen.
|
||||
expect(state.modules.m1).toBeDefined();
|
||||
});
|
||||
|
||||
it('aktiviert kein Modul ohne Metadaten', () => {
|
||||
useCustomModuleCacheStore.getState().activate('unbekannt', slot());
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||
expect(useCustomModuleCacheStore.getState().active).toBeNull();
|
||||
});
|
||||
|
||||
it('deaktiviert nur den eigenen Platzhalter', () => {
|
||||
remember('a');
|
||||
const first = slot();
|
||||
const { activate, deactivate } = useCustomModuleCacheStore.getState();
|
||||
activate('a', first);
|
||||
deactivate(slot());
|
||||
expect(useCustomModuleCacheStore.getState().active?.slot).toBe(first);
|
||||
deactivate(first);
|
||||
expect(useCustomModuleCacheStore.getState().active).toBeNull();
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('vergisst ein Modul samt offenem Rahmen', () => {
|
||||
remember('a');
|
||||
useCustomModuleCacheStore.getState().activate('a', slot());
|
||||
useCustomModuleCacheStore.getState().forget('a');
|
||||
const state = useCustomModuleCacheStore.getState();
|
||||
expect(state.modules).toEqual({});
|
||||
expect(state.openIds).toEqual([]);
|
||||
expect(state.recent).toEqual([]);
|
||||
expect(state.active).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CustomModule } from '@/lib/custom-modules-api';
|
||||
|
||||
/**
|
||||
* Eigene Module offen halten (30.09.2026, Nutzerwunsch „der
|
||||
* Aufruf dauert jedes Mal lange“). Zwei Dinge liegen hier im Speicher der
|
||||
* Sitzung (bewusst OHNE persist — nach dem Neuladen der Seite ist alles leer):
|
||||
*
|
||||
* 1. `modules`: Name und Adresse je bereits geladenem Modul. Ein erneuter
|
||||
* Aufruf zeigt sofort diesen Stand und laedt im Hintergrund nach
|
||||
* (custom-module-view.tsx), statt erst auf `getCustomModule` zu warten.
|
||||
* 2. `openIds`/`recent`: welche Module ihren <iframe> behalten
|
||||
* (custom-module-frame-host.tsx). `openIds` ist die Einhaengereihenfolge
|
||||
* und bleibt STABIL — neue Module kommen hinten dazu, verworfene fallen
|
||||
* heraus. Wuerde die Liste bei jedem Aufruf umsortiert, verschoebe React
|
||||
* die <iframe>-Knoten im DOM, und ein verschobenes <iframe> laedt neu.
|
||||
* Die Nutzungsreihenfolge fuer die Begrenzung steht deshalb getrennt in
|
||||
* `recent` (zuletzt benutzt vorn).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const CUSTOM_MODULE_KEEP_ALIVE_MAX = 5;
|
||||
|
||||
export type CachedCustomModule = Pick<CustomModule, 'id' | 'name' | 'url'>;
|
||||
|
||||
export interface LruResult {
|
||||
/** Neue Nutzungsreihenfolge, zuletzt benutzt vorn. */
|
||||
recent: string[];
|
||||
/** Ids, die dabei ueber die Grenze gefallen sind (aelteste zuerst verworfen). */
|
||||
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 {
|
||||
const next = [id, ...recent.filter((other) => other !== id)];
|
||||
const limit = Math.max(1, max);
|
||||
return { recent: next.slice(0, limit), evicted: next.slice(limit) };
|
||||
}
|
||||
|
||||
export interface ActiveCustomModuleSlot {
|
||||
id: string;
|
||||
/** Platzhalter der Modulseite, ueber den der Behaelter den <iframe> legt. */
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
interface CustomModuleCacheState {
|
||||
modules: Record<string, CachedCustomModule>;
|
||||
openIds: string[];
|
||||
recent: string[];
|
||||
active: ActiveCustomModuleSlot | null;
|
||||
/** Metadaten merken oder aktualisieren (Name/Adresse nach dem Laden oder Speichern). */
|
||||
remember: (mod: CachedCustomModule) => void;
|
||||
/** Modul vergessen (404 oder geloescht): Metadaten UND offener <iframe> weg. */
|
||||
forget: (id: string) => 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. */
|
||||
deactivate: (slot: HTMLElement) => void;
|
||||
/** Abmelden/Benutzerwechsel: alles verwerfen. */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set) => ({
|
||||
modules: {},
|
||||
openIds: [],
|
||||
recent: [],
|
||||
active: null,
|
||||
remember: (mod) =>
|
||||
set((s) => {
|
||||
const prev = s.modules[mod.id];
|
||||
if (prev && prev.name === mod.name && prev.url === mod.url) return s;
|
||||
return { modules: { ...s.modules, [mod.id]: { id: mod.id, name: mod.name, url: mod.url } } };
|
||||
}),
|
||||
forget: (id) =>
|
||||
set((s) => {
|
||||
const { [id]: _removed, ...modules } = s.modules;
|
||||
return {
|
||||
modules,
|
||||
openIds: s.openIds.filter((other) => other !== id),
|
||||
recent: s.recent.filter((other) => other !== id),
|
||||
active: s.active?.id === id ? null : s.active,
|
||||
};
|
||||
}),
|
||||
activate: (id, slot) =>
|
||||
set((s) => {
|
||||
if (!s.modules[id]) return s;
|
||||
const { recent, evicted } = touchLru(s.recent, id, CUSTOM_MODULE_KEEP_ALIVE_MAX);
|
||||
const kept = s.openIds.filter((other) => !evicted.includes(other));
|
||||
return {
|
||||
recent,
|
||||
openIds: kept.includes(id) ? kept : [...kept, id],
|
||||
active: { id, slot },
|
||||
};
|
||||
}),
|
||||
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
|
||||
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
|
||||
}));
|
||||
Reference in New Issue
Block a user