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
+9 -16
View File
@@ -1,14 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Workload } from '$lib/types';
import * as api from '$lib/api';
import { IconPlus, IconRefresh } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { workloadsCache } from '$lib/stores/caches';
import { t } from '$lib/i18n';
let workloads = $state<Workload[]>([]);
let loading = $state(true);
let error = $state('');
// Cache-backed (stale-while-revalidate): the value persists across
// navigation, so revisiting this tab renders the table immediately instead
// of flashing the cold skeleton. The skeleton only shows on the very first
// visit of the session.
const workloads = $derived($workloadsCache.value);
const loading = $derived($workloadsCache.loading);
const error = $derived($workloadsCache.error);
let filter = $state<'all' | string>('all');
// Plugin-native rows are those with a source_kind. trigger_kind is no
@@ -27,17 +30,7 @@
pluginRows.filter((w) => w.source_kind === kind).length
);
async function load() {
loading = true;
error = '';
try {
workloads = await api.listWorkloads();
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.list.loadError');
} finally {
loading = false;
}
}
const load = () => workloadsCache.refresh();
function sourceBadge(kind: string): string {
switch (kind) {
+52 -5
View File
@@ -2,6 +2,7 @@
import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
import * as api from '$lib/api';
@@ -65,15 +66,26 @@
import { fmt } from '$lib/format/datetime';
import { formatBytes } from '$lib/format/bytes';
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
import { workloadDetailCache } from '$lib/stores/caches';
// Route params come back as `string | undefined`; the route file
// guarantees `id` exists, but the empty-string fallback satisfies
// the type checker — server validation rejects empty ids anyway.
const id = $derived($page.params.id ?? '');
let workload = $state<Workload | null>(null);
let containers = $state<Container[]>([]);
let loading = $state(true);
// Warm-seed the workload from the per-id cache so the body — gated on
// `loading && !workload` — skips the full-page skeleton on revisit (the
// other sections populate when load() resolves). Seeding synchronously at
// init (not just in the load effect) avoids even a one-frame skeleton on a
// fresh mount. Edit-mode forms seed on-demand from `workload`, so there is
// no on-load form state to clobber.
const _seed = workloadDetailCache.peek(get(page).params.id ?? '').value;
let workload = $state<Workload | null>(_seed?.workload ?? null);
// Containers are seeded too (not just the workload) so the live-status badge
// + Stop/Start toolbar reflect last-known state on a warm revisit instead of
// momentarily reading the empty array as "not deployed".
let containers = $state<Container[]>(_seed?.containers ?? []);
let loading = $state(_seed == null);
let error = $state('');
let deploying = $state(false);
let deployRef = $state('');
@@ -903,7 +915,28 @@
);
async function load() {
loading = true;
const k = id;
// Reused-component nav (A→B): warm-seed B's header instantly if cached;
// a cold id keeps the skeleton until the fetch resolves.
const cached = workloadDetailCache.peek(k);
if (cached.value) {
workload = cached.value.workload;
containers = cached.value.containers;
// Reset the non-seeded dependent panels so a reused-component warm
// nav (A→B) never renders the previous id's chain / bindings / rules
// / env / volumes under the new id's header; they repopulate when
// this id's Promise.all below resolves.
chain = null;
bindings = [];
logRules = [];
envRows = [];
volumeRows = [];
chainError = '';
logRulesError = '';
bindingsError = '';
} else {
loading = true;
}
error = '';
try {
const [w, c, env, vols, ch, lr, bs] = await Promise.all([
@@ -924,8 +957,12 @@
return [] as WorkloadTriggerBinding[];
})
]);
// Bail if the route id changed mid-flight — a newer load() owns the
// page state and applying this stale result would clobber it.
if (id !== k) return;
workload = w;
containers = c;
workloadDetailCache.set(k, { workload: w, containers: c });
envRows = env;
volumeRows = vols;
chain = ch;
@@ -970,9 +1007,16 @@
// session storage may be disabled — ignore.
}
} catch (e) {
if (id !== k) return;
// Clear the (possibly warm-seeded) workload so a 404 / failed load
// resolves to the clean error page instead of a phantom, interactive
// detail UI for an entity that no longer exists.
workload = null;
error = e instanceof Error ? e.message : $t('apps.detail.loadError');
} finally {
loading = false;
// Only the current id's load may clear the skeleton — a stale load
// returning early must not flip a newer load's loading state.
if (id === k) loading = false;
}
}
@@ -1391,6 +1435,9 @@
error = '';
try {
await api.deletePluginWorkload(id);
// Drop the cached entry so navigating back to this (now-deleted) id
// doesn't warm-seed a phantom detail page.
workloadDetailCache.remove(id);
goto('/apps');
} catch (e) {
error = e instanceof Error ? e.message : $t('apps.detail.deleteError');