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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user