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:
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host';
|
||||
import { CustomModulePreloader } from '@/components/modules/custom-module-preloader';
|
||||
import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier';
|
||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||
@@ -34,6 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<Sidebar />
|
||||
{/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
|
||||
<main
|
||||
data-app-shell-main
|
||||
className="app-shell-main min-h-[calc(100vh-var(--header-height))] transition-all duration-200 p-3"
|
||||
style={
|
||||
mounted ? ({ '--current-sidebar-width': sidebarWidth } as React.CSSProperties) : undefined
|
||||
@@ -55,6 +57,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{/* Eigene Module offen halten (30.09.2026): die <iframe>s leben hier im
|
||||
Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur
|
||||
einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */}
|
||||
{/* Vorladen (quick-261008-who): laedt die vom Benutzer gewaehlten Module
|
||||
kurz NACH dem Start unsichtbar in den Behaelter. Rendert nichts. */}
|
||||
<CustomModulePreloader />
|
||||
<CustomModuleFrameHost />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() }));
|
||||
vi.mock('@/lib/custom-modules-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||
'@/lib/custom-modules-api',
|
||||
);
|
||||
return { ...actual, getCustomModule: mockGetCustomModule };
|
||||
});
|
||||
|
||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||
import { CustomModuleFrameHost } from './custom-module-frame-host';
|
||||
import { CustomModuleView } from './custom-module-view';
|
||||
|
||||
function Portal({ route }: { route: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<main data-app-shell-main>{route ? <CustomModuleView id={route} /> : <p>Dashboard</p>}</main>
|
||||
<CustomModuleFrameHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetCustomModule.mockReset();
|
||||
useCustomModuleCacheStore.getState().clear();
|
||||
// Inhaltsbereich: 1000 x 700 an (200, 60), Innenabstand 12 px.
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (this.hasAttribute('data-app-shell-main')) {
|
||||
return {
|
||||
top: 60,
|
||||
left: 200,
|
||||
width: 1000,
|
||||
height: 700,
|
||||
right: 1200,
|
||||
bottom: 760,
|
||||
x: 200,
|
||||
y: 60,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '12px',
|
||||
}) as CSSStyleDeclaration,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('CustomModuleFrameHost — Vorladen (quick-261008-who)', () => {
|
||||
it('ein vorgeladener Rahmen ist versteckt, mit Sandbox und hat eine Ersatzgroesse', async () => {
|
||||
render(<Portal route={null} />);
|
||||
act(() => {
|
||||
useCustomModuleCacheStore
|
||||
.getState()
|
||||
.preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' });
|
||||
});
|
||||
|
||||
const frames = await screen.findAllByTestId('custom-module-frame');
|
||||
expect(frames).toHaveLength(1);
|
||||
const frame = frames[0] as HTMLIFrameElement;
|
||||
expect(frame.style.visibility).toBe('hidden');
|
||||
expect(frame.style.pointerEvents).toBe('none');
|
||||
expect(frame).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX);
|
||||
expect(frame.getAttribute('allow')).toBe('');
|
||||
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
await waitFor(() => expect(frame.style.width).toBe('976px'));
|
||||
expect(Number.parseFloat(frame.style.height)).toBeGreaterThanOrEqual(320);
|
||||
expect(frame.style.left).toBe('212px');
|
||||
expect(frame.style.top).toBe('72px');
|
||||
});
|
||||
|
||||
it('der erste Klick uebernimmt denselben Knoten ohne Neuladen', async () => {
|
||||
mockGetCustomModule.mockResolvedValue({
|
||||
id: 'a',
|
||||
name: 'Wiki',
|
||||
url: 'https://a.test/',
|
||||
category: 'infrastructure',
|
||||
personal: true,
|
||||
createdAt: '2026-10-08T00:00:00.000Z',
|
||||
updatedAt: '2026-10-08T00:00:00.000Z',
|
||||
});
|
||||
const view = render(<Portal route={null} />);
|
||||
act(() => {
|
||||
useCustomModuleCacheStore
|
||||
.getState()
|
||||
.preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' });
|
||||
});
|
||||
const before = (await screen.findAllByTestId('custom-module-frame'))[0];
|
||||
const srcBefore = before.getAttribute('src');
|
||||
|
||||
view.rerender(<Portal route="a" />);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getAllByTestId('custom-module-frame')[0] as HTMLElement).style.visibility,
|
||||
).toBe('visible'),
|
||||
);
|
||||
const frames = screen.getAllByTestId('custom-module-frame');
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]).toBe(before);
|
||||
expect(frames[0].getAttribute('src')).toBe(srcBefore);
|
||||
});
|
||||
|
||||
it('eine http-Adresse wird nie als Rahmen vorgeladen', () => {
|
||||
render(<Portal route={null} />);
|
||||
act(() => {
|
||||
useCustomModuleCacheStore.getState().preload({ id: 'x', name: 'X', url: 'http://x.test/' });
|
||||
});
|
||||
expect(screen.queryAllByTestId('custom-module-frame')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,15 @@ interface FrameRect {
|
||||
* Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget
|
||||
* (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein <iframe> entsteht nur bei
|
||||
* einer https-Adresse ohne Zugangsdaten (T-9WC-03).
|
||||
*
|
||||
* Vorladen (quick-261008-who): Ein vorgeladener Rahmen entsteht, bevor ein
|
||||
* Platzhalter existiert (`rect === null`). Eine Groesse 0x0 waere falsch — viele
|
||||
* Seiten rechnen ihr Layout fuer die kleine Flaeche und liefern dann etwas
|
||||
* anderes —, deshalb gilt bis zur ersten Messung eine Ersatzgroesse aus dem
|
||||
* Inhaltsbereich (`[data-app-shell-main]`, abzueglich Innenabstand). Die
|
||||
* Uebernahme beim ersten Oeffnen laedt NICHT neu: `openIds` bleibt stabil, der
|
||||
* Rahmen behaelt Elternknoten und `key`, es aendert sich nur sein Stil
|
||||
* (Sichtbarkeit und Groesse; eine Groessenaenderung laedt ein <iframe> nicht neu).
|
||||
*/
|
||||
export function CustomModuleFrameHost() {
|
||||
const modules = useCustomModuleCacheStore((s) => s.modules);
|
||||
@@ -53,6 +62,8 @@ export function CustomModuleFrameHost() {
|
||||
const active = useCustomModuleCacheStore((s) => s.active);
|
||||
const clear = useCustomModuleCacheStore((s) => s.clear);
|
||||
const [rect, setRect] = useState<FrameRect | null>(null);
|
||||
const [fallbackRect, setFallbackRect] = useState<FrameRect | null>(null);
|
||||
const hasFrames = openIds.length > 0;
|
||||
|
||||
// Benutzerwechsel ohne Neuladen der Seite: nichts vom vorigen Benutzer
|
||||
// weiterzeigen. (Das Abmelden selbst leert den Speicher in der Kopfzeile.)
|
||||
@@ -98,6 +109,37 @@ export function CustomModuleFrameHost() {
|
||||
};
|
||||
}, [slot]);
|
||||
|
||||
// Ersatzgroesse aus dem Inhaltsbereich, solange noch kein Platzhalter gemessen
|
||||
// wurde (vorgeladene Rahmen, quick-261008-who).
|
||||
useLayoutEffect(() => {
|
||||
if (!hasFrames || rect !== null) return;
|
||||
const measureFallback = () => {
|
||||
const main = document.querySelector<HTMLElement>('[data-app-shell-main]');
|
||||
if (!main) return;
|
||||
const r = main.getBoundingClientRect();
|
||||
const cs = getComputedStyle(main);
|
||||
const px = (value: string) => Number.parseFloat(value) || 0;
|
||||
const left = r.left + px(cs.paddingLeft);
|
||||
const top = r.top + px(cs.paddingTop);
|
||||
const width = Math.max(0, r.width - px(cs.paddingLeft) - px(cs.paddingRight));
|
||||
const height = Math.max(320, window.innerHeight - top - px(cs.paddingBottom));
|
||||
setFallbackRect((prev) =>
|
||||
prev &&
|
||||
prev.top === top &&
|
||||
prev.left === left &&
|
||||
prev.width === width &&
|
||||
prev.height === height
|
||||
? prev
|
||||
: { top, left, width, height },
|
||||
);
|
||||
};
|
||||
measureFallback();
|
||||
window.addEventListener('resize', measureFallback);
|
||||
return () => window.removeEventListener('resize', measureFallback);
|
||||
}, [hasFrames, rect]);
|
||||
|
||||
const frameRect = rect ?? fallbackRect;
|
||||
|
||||
// Immer derselbe Elternknoten, auch ohne offene Module — sonst haengte React
|
||||
// die <iframe>s beim Wechsel zwischen leer und nicht leer neu ein.
|
||||
return (
|
||||
@@ -116,10 +158,10 @@ export function CustomModuleFrameHost() {
|
||||
referrerPolicy="no-referrer"
|
||||
className="fixed z-10 rounded-lg border-0 bg-background"
|
||||
style={{
|
||||
top: rect?.top ?? 0,
|
||||
left: rect?.left ?? 0,
|
||||
width: rect?.width ?? 0,
|
||||
height: rect?.height ?? 0,
|
||||
top: frameRect?.top ?? 0,
|
||||
left: frameRect?.left ?? 0,
|
||||
width: frameRect?.width ?? 0,
|
||||
height: frameRect?.height ?? 0,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockList } = vi.hoisted(() => ({ mockList: vi.fn() }));
|
||||
vi.mock('@/lib/custom-modules-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||
'@/lib/custom-modules-api',
|
||||
);
|
||||
return { ...actual, listCustomModules: mockList };
|
||||
});
|
||||
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||
import { CustomModulePreloader } from './custom-module-preloader';
|
||||
|
||||
function entry(id: string, url: string, preload?: boolean) {
|
||||
return {
|
||||
id,
|
||||
name: `Modul ${id}`,
|
||||
url,
|
||||
category: 'infrastructure',
|
||||
personal: true,
|
||||
preload,
|
||||
createdAt: '2026-10-08T00:00:00.000Z',
|
||||
updatedAt: '2026-10-08T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
const user = (id: string) => ({
|
||||
id,
|
||||
username: id,
|
||||
displayName: null,
|
||||
role: 'USER' as const,
|
||||
tenantId: 't1',
|
||||
});
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockList.mockReset();
|
||||
useCustomModuleCacheStore.getState().clear();
|
||||
useAuthStore.getState().clearUser();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('CustomModulePreloader', () => {
|
||||
it('laedt nur markierte https-Module vor: A ja, B (nicht markiert) und C (http) nie', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
entry('A', 'https://a.test/', true),
|
||||
entry('B', 'https://b.test/', false),
|
||||
entry('C', 'http://c.test/', true),
|
||||
]);
|
||||
useAuthStore.getState().setUser(user('u1'));
|
||||
render(<CustomModulePreloader />);
|
||||
|
||||
await advance(60_000);
|
||||
const s = useCustomModuleCacheStore.getState();
|
||||
expect(s.openIds).toEqual(['A']);
|
||||
expect(s.pinned).toEqual(['A']);
|
||||
expect(s.active).toBeNull();
|
||||
});
|
||||
|
||||
it('staffelt mehrere Module und laedt nichts vor Ablauf der Startverzoegerung', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
entry('A', 'https://a.test/', true),
|
||||
entry('B', 'https://b.test/', true),
|
||||
]);
|
||||
useAuthStore.getState().setUser(user('u1'));
|
||||
render(<CustomModulePreloader />);
|
||||
|
||||
await advance(1500);
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
await advance(1000);
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['A']);
|
||||
await advance(2000);
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('ohne angemeldeten Benutzer wird nichts vorgeladen, auch nicht beim spaeteren Abmelden', async () => {
|
||||
mockList.mockResolvedValue([entry('A', 'https://a.test/', true)]);
|
||||
render(<CustomModulePreloader />);
|
||||
await advance(60_000);
|
||||
expect(mockList).not.toHaveBeenCalled();
|
||||
|
||||
act(() => useAuthStore.getState().setUser(user('u1')));
|
||||
await advance(1000);
|
||||
act(() => useAuthStore.getState().clearUser());
|
||||
await advance(60_000);
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('Benutzerwechsel A -> B wendet die Liste des alten Benutzers nicht mehr an', async () => {
|
||||
let resolveOld: (v: unknown[]) => void = () => {};
|
||||
mockList.mockImplementationOnce(() => new Promise((r) => (resolveOld = r)));
|
||||
mockList.mockResolvedValueOnce([entry('NEU', 'https://neu.test/', true)]);
|
||||
useAuthStore.getState().setUser(user('u1'));
|
||||
render(<CustomModulePreloader />);
|
||||
await advance(2500);
|
||||
expect(mockList).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => useAuthStore.getState().setUser(user('u2')));
|
||||
resolveOld([entry('ALT', 'https://alt.test/', true)]);
|
||||
await advance(60_000);
|
||||
expect(useCustomModuleCacheStore.getState().openIds).toEqual(['NEU']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared';
|
||||
import { useEffect } from 'react';
|
||||
import { checkCustomModuleUrl, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { startCustomModulePreload } from '@/lib/custom-module-preload';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||
|
||||
/**
|
||||
* Stoesst nach dem Start das Vorladen der vom Benutzer gewaehlten eigenen
|
||||
* Module an (quick-261008-who). Rendert nichts. Die Rahmen selbst entstehen im
|
||||
* Behaelter `CustomModuleFrameHost` (gleicher Speicher, `preload()`).
|
||||
*
|
||||
* Der Effekt haengt an der Benutzerkennung: ohne Benutzer (abgemeldet) startet
|
||||
* nichts; Abmelden oder Benutzerwechsel ruft beim Aufraeumen `cancel()` und
|
||||
* verhindert damit jeden weiteren Schritt (D-04, T-WHO-05). Das Leeren der
|
||||
* schon offenen Rahmen bleibt bei Kopfzeile und Behaelter. Nur Module mit
|
||||
* `preload === true` und einer https-Adresse ohne Zugangsdaten werden
|
||||
* vorgeladen (T-WHO-04); mehr als `CUSTOM_MODULE_PRELOAD_MAX` kuerzt die
|
||||
* Komponente zusaetzlich zur Serverbegrenzung.
|
||||
*/
|
||||
export function CustomModulePreloader() {
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
return startCustomModulePreload({
|
||||
loadTargets: async () => {
|
||||
const all = await listCustomModules();
|
||||
return all
|
||||
.filter((m) => m.preload === true && checkCustomModuleUrl(m.url) === 'ok')
|
||||
.slice(0, CUSTOM_MODULE_PRELOAD_MAX);
|
||||
},
|
||||
preloadOne: (m) =>
|
||||
useCustomModuleCacheStore.getState().preload({ id: m.id, name: m.name, url: m.url }),
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user