feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
Build / build (push) Failing after 4m51s
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:
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user