feat(ui): shared stale-while-revalidate layer — useCachedResource + resources store + rpc-client abort/dedup/retry controls
Demo images / Build & push demo images (push) Successful in 3m28s
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.
- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
with sticky-ready (never regress ready→loading), keep-last-value on
error, per-key in-flight dedup, sessionStorage snapshot hydrate,
debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
hydrate, TTL-gated background revalidate, revalidate-on-focus,
abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e24e0a6473
commit
67454974b2
@@ -4,6 +4,16 @@ export interface RPCOptions {
|
||||
method: string
|
||||
params?: Record<string, unknown>
|
||||
timeout?: number
|
||||
/** Abort the call (and any pending retries) from the outside — pass a
|
||||
* component-scoped controller's signal so fan-outs stop on unmount. */
|
||||
signal?: AbortSignal
|
||||
/** Per-call retry budget (default 3). Use 1 for calls whose caller has its
|
||||
* own timeout/fallback UX — retry×3 on a slow peer is how one unreachable
|
||||
* node turns a 30s timeout into a 90s spinner. */
|
||||
maxRetries?: number
|
||||
/** Collapse concurrent identical calls (same method + params) into one
|
||||
* request. Opt-in: only safe for reads. */
|
||||
dedup?: boolean
|
||||
}
|
||||
|
||||
export interface RPCResponse<T> {
|
||||
@@ -74,18 +84,35 @@ function getCsrfToken(): string | null {
|
||||
class RPCClient {
|
||||
private static _sessionExpiredRedirecting = false
|
||||
private baseUrl: string
|
||||
/** In-flight dedup map for `dedup: true` calls, keyed method+params. */
|
||||
private inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
constructor(baseUrl: string = '/rpc/v1') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000 } = options
|
||||
const maxRetries = 3
|
||||
if (options.dedup) {
|
||||
const key = `${options.method}:${JSON.stringify(options.params ?? {})}`
|
||||
const existing = this.inflight.get(key)
|
||||
if (existing) return existing as Promise<T>
|
||||
const p = this.callInner<T>(options).finally(() => this.inflight.delete(key))
|
||||
this.inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
return this.callInner<T>(options)
|
||||
}
|
||||
|
||||
private async callInner<T>(options: RPCOptions): Promise<T> {
|
||||
const { method, params = {}, timeout = 15000, signal: external } = options
|
||||
const maxRetries = Math.max(1, options.maxRetries ?? 3)
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
const onExternalAbort = () => controller.abort()
|
||||
external?.addEventListener('abort', onExternalAbort, { once: true })
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -105,6 +132,7 @@ class RPCClient {
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — debounced redirect to login
|
||||
@@ -167,8 +195,11 @@ class RPCClient {
|
||||
return data.result as T
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
external?.removeEventListener('abort', onExternalAbort)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Caller-initiated abort is final — never retried.
|
||||
if (external?.aborted) throw new Error('Aborted')
|
||||
const timeoutErr = new Error('Request timeout')
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = 600 * (attempt + 1)
|
||||
|
||||
Reference in New Issue
Block a user