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:
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Stale-while-revalidate resource cache.
|
||||
*
|
||||
* Backs list pages so that switching between sidebar tabs doesn't flash an
|
||||
* empty/skeleton frame on every visit. The first visit to a page does a cold
|
||||
* load (skeleton shown once); every subsequent visit renders the previously
|
||||
* loaded value immediately while a background refresh keeps it current.
|
||||
*
|
||||
* The cache is module-scoped (one instance per resource, created in
|
||||
* `caches.ts`) so its value survives component unmount/remount across
|
||||
* client-side navigation — that persistence is what removes the flicker.
|
||||
*
|
||||
* Style mirrors `navCounts.ts`: a classic `writable` exposed as a `Readable`
|
||||
* plus imperative `refresh()`/`set()` methods, so pages keep using `$store`
|
||||
* auto-subscription.
|
||||
*/
|
||||
|
||||
import { writable, get, type Readable } from 'svelte/store';
|
||||
|
||||
export interface ResourceState<T> {
|
||||
/** Last successfully loaded value (or the initial seed before first load). */
|
||||
value: T;
|
||||
/**
|
||||
* True only during the *cold* load — i.e. the resource has never loaded
|
||||
* successfully and there is no error yet. Drives the skeleton. Stays false
|
||||
* for background refreshes so revisits never flash.
|
||||
*/
|
||||
loading: boolean;
|
||||
/** True while any fetch (cold or background) is in flight. */
|
||||
refreshing: boolean;
|
||||
/** Last error message, or '' when the most recent fetch succeeded. */
|
||||
error: string;
|
||||
/** Whether at least one fetch has ever succeeded. */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceCache<T> extends Readable<ResourceState<T>> {
|
||||
/**
|
||||
* (Re)fetch the resource. Aborts any in-flight fetch so the latest call
|
||||
* wins — important after a mutation, where a stale background refresh must
|
||||
* not clobber fresh data. Safe to call from `onMount` on every visit.
|
||||
*/
|
||||
refresh(): Promise<void>;
|
||||
/** Optimistically replace the cached value (e.g. after a local mutation). */
|
||||
set(value: T): void;
|
||||
/** Clear back to the cold/initial state (e.g. on logout). */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createResourceCache<T>(
|
||||
fetcher: (signal?: AbortSignal) => Promise<T>,
|
||||
initial: T
|
||||
): ResourceCache<T> {
|
||||
const seed: ResourceState<T> = {
|
||||
value: initial,
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
error: '',
|
||||
loaded: false
|
||||
};
|
||||
const store = writable<ResourceState<T>>(seed);
|
||||
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
// Latest-wins: cancel a previous in-flight fetch so a slow background
|
||||
// refresh can't overwrite a newer (e.g. post-mutation) result.
|
||||
if (inFlight) inFlight.abort();
|
||||
const ac = new AbortController();
|
||||
inFlight = ac;
|
||||
|
||||
store.update((s) => ({ ...s, refreshing: true, error: '' }));
|
||||
try {
|
||||
const value = await fetcher(ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
store.set({ value, loading: false, refreshing: false, error: '', loaded: true });
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return;
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
// Keep the last good value (stale-while-revalidate) and surface the
|
||||
// error; clear `loading` so the cold skeleton resolves to either data
|
||||
// or an error state rather than spinning forever.
|
||||
store.update((s) => ({ ...s, loading: false, refreshing: false, error }));
|
||||
} finally {
|
||||
if (inFlight === ac) inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function set(value: T): void {
|
||||
store.update((s) => ({ ...s, value, loaded: true, loading: false }));
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (inFlight) {
|
||||
inFlight.abort();
|
||||
inFlight = null;
|
||||
}
|
||||
store.set({ ...seed, value: initial });
|
||||
}
|
||||
|
||||
return { subscribe: store.subscribe, refresh, set, reset };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Keyed (per-id) variant — for detail pages fetched by a route param.
|
||||
//
|
||||
// A single-value cache can't serve detail pages (each `[id]` is different
|
||||
// data). This holds a Map<id, state> so revisiting (or A→B→A navigating) a
|
||||
// detail renders the cached entity instantly while revalidating that id.
|
||||
// Pages read reactively via `$cache.get(id)` (the store value is the Map) and
|
||||
// call `cache.refresh(id)` / `cache.peek(id)` (methods on the object).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface KeyedResourceState<T> {
|
||||
/** Last loaded value for this key, or null before its first load. */
|
||||
value: T | null;
|
||||
/** True on the cold load for this key (never loaded, no error). */
|
||||
loading: boolean;
|
||||
/** True while a fetch for this key is in flight. */
|
||||
refreshing: boolean;
|
||||
error: string;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
function coldKeyed<T>(): KeyedResourceState<T> {
|
||||
return { value: null, loading: true, refreshing: false, error: '', loaded: false };
|
||||
}
|
||||
|
||||
export interface KeyedResourceCache<T> extends Readable<Map<string, KeyedResourceState<T>>> {
|
||||
/** (Re)fetch one key; aborts a prior in-flight fetch for the same key. */
|
||||
refresh(key: string): Promise<void>;
|
||||
/** Optimistically replace one key's value (e.g. after a mutation). */
|
||||
set(key: string, value: T): void;
|
||||
/** Non-reactive snapshot read; returns the cold state if the key is absent. */
|
||||
peek(key: string): KeyedResourceState<T>;
|
||||
/** Drop a single key (e.g. after deleting that entity) so a later visit
|
||||
* doesn't warm-seed a stale/phantom value. */
|
||||
remove(key: string): void;
|
||||
/** Drop all keys (e.g. on logout). */
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export function createKeyedResourceCache<T>(
|
||||
fetcher: (key: string, signal?: AbortSignal) => Promise<T>
|
||||
): KeyedResourceCache<T> {
|
||||
const store = writable<Map<string, KeyedResourceState<T>>>(new Map());
|
||||
const inFlight = new Map<string, AbortController>();
|
||||
|
||||
function patch(key: string, partial: Partial<KeyedResourceState<T>>): void {
|
||||
store.update((m) => {
|
||||
const next = new Map(m);
|
||||
next.set(key, { ...(next.get(key) ?? coldKeyed<T>()), ...partial });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh(key: string): Promise<void> {
|
||||
const prev = inFlight.get(key);
|
||||
if (prev) prev.abort();
|
||||
const ac = new AbortController();
|
||||
inFlight.set(key, ac);
|
||||
|
||||
patch(key, { refreshing: true, error: '' });
|
||||
try {
|
||||
const value = await fetcher(key, ac.signal);
|
||||
if (ac.signal.aborted) return;
|
||||
patch(key, { value, loaded: true, loading: false, refreshing: false, error: '' });
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return;
|
||||
patch(key, {
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
} finally {
|
||||
if (inFlight.get(key) === ac) inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function set(key: string, value: T): void {
|
||||
patch(key, { value, loaded: true, loading: false });
|
||||
}
|
||||
|
||||
function peek(key: string): KeyedResourceState<T> {
|
||||
return get(store).get(key) ?? coldKeyed<T>();
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
const ac = inFlight.get(key);
|
||||
if (ac) {
|
||||
ac.abort();
|
||||
inFlight.delete(key);
|
||||
}
|
||||
store.update((m) => {
|
||||
if (!m.has(key)) return m;
|
||||
const next = new Map(m);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
for (const ac of inFlight.values()) ac.abort();
|
||||
inFlight.clear();
|
||||
store.set(new Map());
|
||||
}
|
||||
|
||||
return { subscribe: store.subscribe, refresh, set, peek, remove, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user