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
+13 -23
View File
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ContainerView, WorkloadKind } from '$lib/types';
import * as api from '$lib/api';
import { containersCache } from '$lib/stores/caches';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
@@ -14,10 +14,15 @@
// allContainers holds the unfiltered list — kind/state filters are applied
// client-side so the tab counters reflect the whole population, not the
// current narrowed view (otherwise picking "Project" would show All=0).
let allContainers = $state<ContainerView[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let error = $state('');
//
// Cache-backed (stale-while-revalidate): the list persists across
// navigation, so revisiting this tab renders immediately instead of
// flashing the cold skeleton. The cache's abort-on-refresh replaces the
// previous manual `loadSeq` race guard.
const allContainers = $derived($containersCache.value);
const loading = $derived($containersCache.loading);
const refreshing = $derived($containersCache.refreshing);
const error = $derived($containersCache.error);
// Filters seed from query string so the tab is shareable / refresh-safe.
const initialKind = (() => {
@@ -32,24 +37,9 @@
let stateFilter = $state(initialState);
let searchTerm = $state(initialQ);
async function load(initial: boolean): Promise<void> {
if (initial) loading = true;
else refreshing = true;
error = '';
try {
// Race-safety: keep the latest fetch's result and discard stragglers.
const seq = ++loadSeq;
const containers = await api.listContainers({});
if (seq !== loadSeq) return;
allContainers = containers;
} catch (e) {
error = e instanceof Error ? e.message : $t('containers.errLoad');
} finally {
loading = false;
refreshing = false;
}
}
let loadSeq = 0;
// `initial` is retained for call-site compatibility; the cache distinguishes
// the cold load (skeleton) from background refreshes on its own.
const load = (_initial = false): Promise<void> => containersCache.refresh();
$effect(() => {
void load(true);
+10 -29
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { StaleContainer } from '$lib/types';
import * as api from '$lib/api';
import { staleContainersCache } from '$lib/stores/caches';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
@@ -10,37 +10,19 @@
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let containers = $state<StaleContainer[]>([]);
let loading = $state(true);
let error = $state('');
// Cache-backed (stale-while-revalidate) so revisiting renders instantly
// instead of flashing the cold skeleton; the cache aborts a prior in-flight
// fetch on the next refresh (replacing the old manual loadController guard).
const containers = $derived($staleContainersCache.value);
const loading = $derived($staleContainersCache.loading);
const error = $derived($staleContainersCache.error);
let confirmSingleId = $state('');
let confirmBulk = $state(false);
let cleaningIds = $state<Set<string>>(new Set());
let bulkCleaning = $state(false);
let loadController: AbortController | null = null;
async function loadStale() {
loadController?.abort();
const ac = new AbortController();
loadController = ac;
loading = true;
error = '';
try {
const rows = await api.fetchStaleContainers(ac.signal);
if (ac.signal.aborted) return;
containers = rows;
} catch (e) {
if (ac.signal.aborted) return;
error = e instanceof Error ? e.message : $t('stale.loadFailed');
} finally {
if (loadController === ac) {
loading = false;
loadController = null;
}
}
}
const loadStale = () => staleContainersCache.refresh();
function requestCleanup(id: string) {
confirmSingleId = id;
@@ -52,7 +34,7 @@
cleaningIds = new Set([...cleaningIds, id]);
try {
await api.cleanupStaleContainer(id);
containers = containers.filter((c) => c.container.id !== id);
await staleContainersCache.refresh();
toasts.success($t('stale.cleanedUp'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
@@ -68,7 +50,7 @@
bulkCleaning = true;
try {
const result = await api.bulkCleanupStaleContainers();
containers = [];
await staleContainersCache.refresh();
toasts.success($t('stale.bulkCleanedUp', { count: String(result.deleted) }));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('stale.cleanupFailed'));
@@ -79,7 +61,6 @@
$effect(() => {
loadStale();
return () => loadController?.abort();
});
</script>