192204a51c
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).
210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|