feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Build / build (push) Failing after 4m51s

Sidebar tabs, Settings, and drill-in detail pages re-fetched on every
visit (loading=true + onMount), flashing an empty skeleton frame on each
navigation. Add an SWR cache layer so revisiting a view renders cached
data instantly while refreshing in the background.

- resourceCache.ts: single-value + keyed (per-id) SWR cache factories
- caches.ts: per-resource cache instances; resetAllCaches() on logout
- eventsSnapshot.ts: warm-seed snapshot for the SSE/paginated events page
- List/sidebar pages read $cache.value via $derived, refresh() on mount;
  mutations refresh the cache
- Settings forms seed once from settingsCache (edit-safe) and refetch
  after save (PUT /api/settings returns {status}, not the Settings object)
- Detail [id] pages warm-seed per id; apps/[id] seeds {workload,containers},
  resets non-seeded panels on warm nav, clears workload on 404, and
  invalidates its cache entry on delete

Deferred (still cold-fetch): triggers/[id] (webhook secret + multi-fetch
body gate), apps/new (create wizard).
This commit is contained in:
2026-06-08 15:39:25 +03:00
parent 6b45ed62bb
commit 192204a51c
31 changed files with 1139 additions and 493 deletions
@@ -8,13 +8,16 @@
detail pages — this page deliberately does not surface them.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import {
getSettings, updateSettings,
updateSettings,
getSettingsNotificationSecret,
regenerateSettingsNotificationSecret,
disableSettingsNotificationSigning,
testSettingsNotification,
} from '$lib/api';
import { settingsCache } from '$lib/stores/caches';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
@@ -36,17 +39,24 @@
try { new URL(value.trim()); return ''; } catch { return $t('validation.invalidUrl'); }
}
// Seed once from the shared settings cache (warm → no skeleton on revisit);
// never re-applied during background refresh, so unsaved edits are safe.
async function load() {
loading = true;
try {
const settings = await getSettings();
notificationUrl = settings.notification_url ?? '';
const cached = get(settingsCache).value;
if (cached) {
notificationUrl = cached.notification_url ?? '';
savedNotificationUrl = notificationUrl;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
loading = false;
}
await settingsCache.refresh();
const fresh = get(settingsCache).value;
if (fresh && loading) {
notificationUrl = fresh.notification_url ?? '';
savedNotificationUrl = notificationUrl;
}
loading = false;
const { error } = get(settingsCache);
if (error && !cached) toasts.error(error || $t('settingsGeneral.loadFailed'));
}
async function handleSave() {
@@ -56,6 +66,8 @@
saving = true;
try {
await updateSettings({ notification_url: notificationUrl.trim() });
// PUT returns {status:"updated"}, not Settings — refetch the cache.
await settingsCache.refresh();
savedNotificationUrl = notificationUrl.trim();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
@@ -65,7 +77,7 @@
}
}
$effect(() => { load(); });
onMount(load);
</script>
<svelte:head>