Files
tiny-forge/web/src/lib/stores/resourceCache.ts
T
alexei.dolgolyov 192204a51c
Build / build (push) Failing after 4m51s
feat(web): stale-while-revalidate caches to eliminate tab-switch flicker
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).
2026-06-08 15:39:25 +03:00

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 };
}