feat(quick-261008-who): eigene Module beim Start vorladen — Durchstich

- Spalte User.customModulePreloadIds, PUT /custom-modules/:id/preload (Grenze 8, 404 fuer Fremdes)
- Vorlade-Planer und -Komponente, Speicher preload()/pinned, Ersatzgroesse im Behaelter
- E2E-Skript gegen die API

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 09:14:45 +02:00
parent 1bb869a7b5
commit ac104495b4
23 changed files with 1139 additions and 42 deletions
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { startCustomModulePreload } from './custom-module-preload';
describe('startCustomModulePreload', () => {
let idleCallbacks: Array<{ id: number; cb: () => void; opts?: { timeout?: number } }>;
let nextIdleId: number;
let cancelIdle: ReturnType<typeof vi.fn>;
/** Fuehrt alle wartenden Leerlauf-Rueckrufe aus (der Browser ist „im Leerlauf“). */
const runIdle = () => {
const pending = idleCallbacks;
idleCallbacks = [];
for (const item of pending) item.cb();
};
beforeEach(() => {
vi.useFakeTimers();
idleCallbacks = [];
nextIdleId = 1;
cancelIdle = vi.fn((id: number) => {
idleCallbacks = idleCallbacks.filter((c) => c.id !== id);
});
vi.stubGlobal(
'requestIdleCallback',
vi.fn((cb: () => void, opts?: { timeout?: number }) => {
const id = nextIdleId++;
idleCallbacks.push({ id, cb, opts });
return id;
}),
);
vi.stubGlobal('cancelIdleCallback', cancelIdle);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const setup = (targets: string[] = ['a', 'b', 'c']) => {
const loadTargets = vi.fn(async () => targets);
const preloadOne = vi.fn();
const cancel = startCustomModulePreload({ loadTargets, preloadOne });
return { loadTargets, preloadOne, cancel };
};
it('ruft vor Ablauf der Startverzoegerung nichts auf', async () => {
const { loadTargets, preloadOne } = setup();
await vi.advanceTimersByTimeAsync(1999);
runIdle();
expect(loadTargets).not.toHaveBeenCalled();
expect(preloadOne).not.toHaveBeenCalled();
});
it('laedt nach der Startverzoegerung im Leerlauf, dann gestaffelt in Reihenfolge', async () => {
const { loadTargets, preloadOne } = setup();
await vi.advanceTimersByTimeAsync(2000);
expect(idleCallbacks[0].opts).toEqual({ timeout: 3000 });
runIdle();
await vi.advanceTimersByTimeAsync(0);
expect(loadTargets).toHaveBeenCalledTimes(1);
runIdle();
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a']);
await vi.advanceTimersByTimeAsync(1499);
runIdle();
expect(preloadOne).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
runIdle();
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a', 'b']);
await vi.advanceTimersByTimeAsync(1500);
runIdle();
expect(preloadOne.mock.calls.map((c) => c[0])).toEqual(['a', 'b', 'c']);
});
it('cancel vor dem Start verhindert jeden Aufruf', async () => {
const { loadTargets, preloadOne, cancel } = setup();
cancel();
await vi.advanceTimersByTimeAsync(10000);
runIdle();
expect(loadTargets).not.toHaveBeenCalled();
expect(preloadOne).not.toHaveBeenCalled();
});
it('cancel zwischen zwei Schritten verhindert den naechsten', async () => {
const { preloadOne, cancel } = setup();
await vi.advanceTimersByTimeAsync(2000);
runIdle();
await vi.advanceTimersByTimeAsync(0);
runIdle();
expect(preloadOne).toHaveBeenCalledTimes(1);
cancel();
await vi.advanceTimersByTimeAsync(10000);
runIdle();
expect(preloadOne).toHaveBeenCalledTimes(1);
});
it('cancel waehrend eines offenen Leerlauf-Rueckrufs bricht ihn ab', async () => {
const { loadTargets, cancel } = setup();
await vi.advanceTimersByTimeAsync(2000);
expect(idleCallbacks).toHaveLength(1);
cancel();
expect(cancelIdle).toHaveBeenCalled();
runIdle();
expect(loadTargets).not.toHaveBeenCalled();
});
it('cancel waehrend loadTargets laeuft verhindert das Vorladen', async () => {
let resolveTargets: (v: string[]) => void = () => {};
const loadTargets = vi.fn(() => new Promise<string[]>((r) => (resolveTargets = r)));
const preloadOne = vi.fn();
const cancel = startCustomModulePreload({ loadTargets, preloadOne });
await vi.advanceTimersByTimeAsync(2000);
runIdle();
cancel();
resolveTargets(['a']);
await vi.advanceTimersByTimeAsync(5000);
runIdle();
expect(preloadOne).not.toHaveBeenCalled();
});
it('ohne requestIdleCallback greift der Zeitgeber-Ersatz', async () => {
vi.stubGlobal('requestIdleCallback', undefined);
const loadTargets = vi.fn(async () => ['a']);
const preloadOne = vi.fn();
startCustomModulePreload({ loadTargets, preloadOne });
await vi.advanceTimersByTimeAsync(2000);
await vi.advanceTimersByTimeAsync(5);
expect(loadTargets).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5);
expect(preloadOne).toHaveBeenCalledWith('a');
});
it('Fehler beim Laden der Ziele bleiben still', async () => {
const preloadOne = vi.fn();
startCustomModulePreload({
loadTargets: async () => {
throw new Error('netz');
},
preloadOne,
});
await vi.advanceTimersByTimeAsync(2000);
runIdle();
await vi.advanceTimersByTimeAsync(0);
expect(preloadOne).not.toHaveBeenCalled();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Vorlade-Planer fuer eigene Module (quick-261008-who).
*
* Warum verzoegert und gestaffelt: Tessera selbst soll zuerst vollstaendig und
* schnell erscheinen. Jede vorgeladene Fremdseite holt Skripte, Bilder und
* Abfragen und liefe sonst gleichzeitig mit dem Start. Deshalb
* 1. erst `initialDelayMs` nach dem Start,
* 2. dann im Leerlauf des Browsers (`requestIdleCallback`, sonst ein
* sofortiger Zeitgeber als Ersatz),
* 3. ein Modul nach dem anderen mit `gapMs` Abstand.
* `cancel()` stoppt alles — auch einen schon offenen Leerlauf-Rueckruf und ein
* noch laufendes `loadTargets` (Abmelden/Benutzerwechsel, T-WHO-05).
*/
export interface CustomModulePreloadOptions<T> {
/** Liefert die vorzuladenden Ziele (wird erst nach Startverzoegerung und Leerlauf aufgerufen). */
loadTargets: () => Promise<T[]>;
/** Laedt ein Ziel vor. */
preloadOne: (target: T) => void;
initialDelayMs?: number;
gapMs?: number;
idleTimeoutMs?: number;
}
export function startCustomModulePreload<T>({
loadTargets,
preloadOne,
initialDelayMs = 2000,
gapMs = 1500,
idleTimeoutMs = 3000,
}: CustomModulePreloadOptions<T>): () => void {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let idleHandle: number | null = null;
let idleFallback: ReturnType<typeof setTimeout> | null = null;
const clearPending = () => {
if (timer !== null) clearTimeout(timer);
if (idleFallback !== null) clearTimeout(idleFallback);
if (idleHandle !== null && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(idleHandle);
}
timer = null;
idleFallback = null;
idleHandle = null;
};
const waitIdle = (run: () => void) => {
if (cancelled) return;
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
idleHandle = window.requestIdleCallback(
() => {
idleHandle = null;
if (!cancelled) run();
},
{ timeout: idleTimeoutMs },
);
} else {
idleFallback = setTimeout(() => {
idleFallback = null;
if (!cancelled) run();
}, 0);
}
};
const step = (targets: T[], index: number) => {
if (cancelled || index >= targets.length) return;
waitIdle(() => {
preloadOne(targets[index]);
if (index + 1 < targets.length) {
timer = setTimeout(() => {
timer = null;
step(targets, index + 1);
}, gapMs);
}
});
};
timer = setTimeout(() => {
timer = null;
waitIdle(() => {
loadTargets()
.then((targets) => {
if (!cancelled) step(targets, 0);
})
.catch(() => {
// Vorladen ist Komfort — Fehler beim Laden der Ziele bleiben still.
});
});
}, initialDelayMs);
return () => {
cancelled = true;
clearPending();
};
}
@@ -6,6 +6,7 @@ import {
deleteCustomModule,
getCustomModule,
listCustomModules,
setCustomModulePreload,
updateCustomModule,
} from './custom-modules-api';
@@ -79,3 +80,35 @@ describe('custom-modules-api', () => {
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
});
});
describe('setCustomModulePreload (quick-261008-who)', () => {
it('ruft PUT /custom-modules/:id/preload mit JSON-Koerper und credentials include', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'a b', preload: true, preloadCount: 1 }),
});
const res = await setCustomModulePreload('a b', true);
expect(res).toEqual({ id: 'a b', preload: true, preloadCount: 1 });
const [url, init] = mockFetch.mock.calls[0];
expect(String(url)).toMatch(/\/custom-modules\/a%20b\/preload$/);
expect(init.method).toBe('PUT');
expect(init.credentials).toBe('include');
expect(JSON.parse(init.body)).toEqual({ preload: true });
});
it('409 bleibt als CustomModuleRequestError mit Status erkennbar', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 409,
json: async () => ({ message: 'Grenze' }),
});
await expect(setCustomModulePreload('a', true)).rejects.toMatchObject({
status: 409,
message: 'Grenze',
});
await expect(setCustomModulePreload('a', true)).rejects.toBeInstanceOf(
CustomModuleRequestError,
);
});
});
+23
View File
@@ -17,6 +17,11 @@ export interface CustomModule {
sortOrder?: number | null;
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
personal: boolean;
/**
* quick-261008-who: Der angemeldete Benutzer laesst dieses Modul beim Start
* vorladen. Fehlt in Antworten von Anlegen/Aendern (dort nicht gelesen).
*/
preload?: boolean;
createdAt: string;
updatedAt: string;
}
@@ -127,3 +132,21 @@ export async function deleteCustomModule(id: string): Promise<void> {
});
if (!res.ok) throw await failure(res);
}
/**
* „Beim Start vorladen“ fuer das angemeldete Benutzerkonto schalten
* (quick-261008-who). 409 = Grenze erreicht (`CustomModuleRequestError.status`).
*/
export async function setCustomModulePreload(
id: string,
preload: boolean,
): Promise<{ id: string; preload: boolean; preloadCount: number }> {
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}/preload`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preload }),
});
if (!res.ok) throw await failure(res);
return res.json();
}
@@ -1,3 +1,4 @@
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import {
CUSTOM_MODULE_KEEP_ALIVE_MAX,
@@ -81,3 +82,55 @@ describe('useCustomModuleCacheStore', () => {
expect(state.active).toBeNull();
});
});
describe('useCustomModuleCacheStore — preload (quick-261008-who)', () => {
const mod = (id: string) => ({ id, name: id, url: `https://x.test/${id}` });
beforeEach(() => {
useCustomModuleCacheStore.getState().clear();
});
it('merkt Metadaten, haengt hinten an openIds, pinnt und laesst active unberuehrt', () => {
useCustomModuleCacheStore.getState().preload(mod('a'));
useCustomModuleCacheStore.getState().preload(mod('b'));
const s = useCustomModuleCacheStore.getState();
expect(s.modules.a).toEqual(mod('a'));
expect(s.openIds).toEqual(['a', 'b']);
expect(s.pinned).toEqual(['a', 'b']);
expect(s.active).toBeNull();
});
it('zweimal aufgerufen aendert nichts', () => {
useCustomModuleCacheStore.getState().preload(mod('a'));
const before = useCustomModuleCacheStore.getState();
useCustomModuleCacheStore.getState().preload(mod('a'));
expect(useCustomModuleCacheStore.getState()).toBe(before);
});
it('ignoriert mehr als CUSTOM_MODULE_PRELOAD_MAX gepinnte', () => {
for (let i = 0; i < CUSTOM_MODULE_PRELOAD_MAX + 2; i++) {
useCustomModuleCacheStore.getState().preload(mod(`m${i}`));
}
const s = useCustomModuleCacheStore.getState();
expect(s.pinned).toHaveLength(CUSTOM_MODULE_PRELOAD_MAX);
expect(s.openIds).toHaveLength(CUSTOM_MODULE_PRELOAD_MAX);
});
it('forget und clear entfernen auch aus pinned', () => {
useCustomModuleCacheStore.getState().preload(mod('a'));
useCustomModuleCacheStore.getState().preload(mod('b'));
useCustomModuleCacheStore.getState().forget('a');
expect(useCustomModuleCacheStore.getState().pinned).toEqual(['b']);
useCustomModuleCacheStore.getState().clear();
expect(useCustomModuleCacheStore.getState().pinned).toEqual([]);
});
it('activate eines gepinnten Moduls laesst es gepinnt', () => {
useCustomModuleCacheStore.getState().preload(mod('a'));
useCustomModuleCacheStore.getState().activate('a', document.createElement('div'));
const s = useCustomModuleCacheStore.getState();
expect(s.pinned).toEqual(['a']);
expect(s.openIds).toEqual(['a']);
expect(s.active?.id).toBe('a');
});
});
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
import type { CustomModule } from '@/lib/custom-modules-api';
/**
@@ -52,11 +53,22 @@ interface CustomModuleCacheState {
modules: Record<string, CachedCustomModule>;
openIds: string[];
recent: string[];
/**
* Vorgeladene („gepinnte“) Module (quick-261008-who). Invariante: jede
* gepinnte id steht auch in `openIds`. Gepinnte werden nie verworfen.
*/
pinned: 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;
/**
* Beim Start vorladen (quick-261008-who): Metadaten merken, den Rahmen hinten
* in `openIds` einhaengen und das Modul pinnen — OHNE `active` zu aendern.
* Mehrfachaufruf und Aufrufe jenseits von `CUSTOM_MODULE_PRELOAD_MAX` bewirken nichts.
*/
preload: (mod: CachedCustomModule) => 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. */
@@ -69,6 +81,7 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
modules: {},
openIds: [],
recent: [],
pinned: [],
active: null,
remember: (mod) =>
set((s) => {
@@ -83,9 +96,20 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
modules,
openIds: s.openIds.filter((other) => other !== id),
recent: s.recent.filter((other) => other !== id),
pinned: s.pinned.filter((other) => other !== id),
active: s.active?.id === id ? null : s.active,
};
}),
preload: (mod) =>
set((s) => {
if (s.pinned.includes(mod.id)) return s;
if (s.pinned.length >= CUSTOM_MODULE_PRELOAD_MAX) return s;
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],
};
}),
activate: (id, slot) =>
set((s) => {
if (!s.modules[id]) return s;
@@ -98,5 +122,5 @@ export const useCustomModuleCacheStore = create<CustomModuleCacheState>()((set)
};
}),
deactivate: (slot) => set((s) => (s.active?.slot === slot ? { active: null } : s)),
clear: () => set({ modules: {}, openIds: [], recent: [], active: null }),
clear: () => set({ modules: {}, openIds: [], recent: [], pinned: [], active: null }),
}));