diff --git a/src/components/boundaries/CascadeContext.tsx b/src/components/boundaries/CascadeContext.tsx new file mode 100644 index 0000000..8276390 --- /dev/null +++ b/src/components/boundaries/CascadeContext.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; + +/** + * Fallback-cascade context. A composite boundary blocks its subtree when a + * critical child fails, so downstream resources can skip fetching (saving + * bandwidth) rather than each showing their own loader. + */ + +export interface CascadeValue { + blocked: boolean; + block: () => void; + reset: () => void; +} + +const CascadeContext = createContext({ + blocked: false, + block: () => {}, + reset: () => {}, +}); + +export function CascadeProvider({ children }: { children: ReactNode }) { + const [blocked, setBlocked] = useState(false); + const value = useMemo( + () => ({ blocked, block: () => setBlocked(true), reset: () => setBlocked(false) }), + [blocked] + ); + return {children}; +} + +export function useCascade(): CascadeValue { + return useContext(CascadeContext); +} + +/** + * Renders `children` only when the cascade is not blocked; otherwise renders + * `whenBlocked` (default: nothing) — used to skip telemetry fetching when the + * blockchain boundary has failed. + */ +export function CascadeGate({ + children, + whenBlocked = null, +}: { + children: ReactNode; + whenBlocked?: ReactNode; +}) { + const { blocked } = useCascade(); + return <>{blocked ? whenBlocked : children}; +} diff --git a/src/components/boundaries/DomainBoundary.tsx b/src/components/boundaries/DomainBoundary.tsx new file mode 100644 index 0000000..dd4f43b --- /dev/null +++ b/src/components/boundaries/DomainBoundary.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Suspense, useCallback, useState, type ReactNode } from "react"; +import { ErrorBoundary } from "@/components/boundaries/ErrorBoundary"; +import { ErrorPanel } from "@/components/boundaries/ErrorPanel"; +import { cacheStore } from "@/store/slices/cacheSlice"; + +/** + * Generic named boundary: an {@link ErrorBoundary} wrapping a `Suspense`. + * + * On Retry it invalidates the boundary's cache groups (setting them stale so the + * next read re-fetches) and remounts via a key bump, which clears the error and + * re-runs the suspending children. + */ + +export interface DomainBoundaryProps { + /** Cache groups invalidated on retry (e.g. ["blockchain", "telemetry"]). */ + groups: string[]; + /** Suspense fallback (skeleton). */ + fallback: ReactNode; + errorTitle: string; + children: ReactNode; + /** Called when an error is first caught (e.g. to set the cascade flag). */ + onError?: (error: Error) => void; + /** Called on Retry, before invalidation + remount (e.g. reset the cascade). */ + onRetry?: () => void; +} + +export function DomainBoundary({ + groups, + fallback, + errorTitle, + children, + onError, + onRetry, +}: DomainBoundaryProps) { + const [attempt, setAttempt] = useState(0); + const groupsKey = groups.join(","); + + const retry = useCallback(() => { + onRetry?.(); + for (const group of groupsKey.split(",")) { + if (group) cacheStore.invalidateGroup(group); + } + setAttempt((a) => a + 1); + }, [groupsKey, onRetry]); + + return ( + ( + + )} + > + {children} + + ); +} diff --git a/src/components/boundaries/ErrorBoundary.tsx b/src/components/boundaries/ErrorBoundary.tsx new file mode 100644 index 0000000..824949c --- /dev/null +++ b/src/components/boundaries/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from "react"; + +/** + * Generic error boundary. Catches errors thrown during render (including a + * Suspense resource's rejected promise) and renders a fallback. Remounting it + * with a new `key` clears the error — the retry mechanism the boundaries use. + */ + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback: (error: Error) => ReactNode; + /** Invoked when an error is first caught (e.g. to set a cascade flag). */ + onError?: (error: Error) => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, _info: ErrorInfo): void { + this.props.onError?.(error); + } + + render(): ReactNode { + if (this.state.error) return this.props.fallback(this.state.error); + return this.props.children; + } +} diff --git a/src/components/boundaries/ErrorPanel.tsx b/src/components/boundaries/ErrorPanel.tsx new file mode 100644 index 0000000..348cea5 --- /dev/null +++ b/src/components/boundaries/ErrorPanel.tsx @@ -0,0 +1,34 @@ +"use client"; + +/** Error fallback with a Retry action, shown when a boundary catches an error. */ + +export interface ErrorPanelProps { + title: string; + error: Error; + onRetry: () => void; + className?: string; +} + +export function ErrorPanel({ title, error, onRetry, className }: ErrorPanelProps) { + return ( +
+
+
+

{title}

+

+ {error.message} +

+
+ +
+
+ ); +} diff --git a/src/components/boundaries/Skeletons.tsx b/src/components/boundaries/Skeletons.tsx new file mode 100644 index 0000000..223443d --- /dev/null +++ b/src/components/boundaries/Skeletons.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { DomainKey } from "@/types/suspense"; + +/** Domain-specific skeleton loaders shown as Suspense fallbacks. */ + +const DOMAIN_LABEL: Record = { + blockchain: "Loading contract state…", + telemetry: "Connecting telemetry…", + metadata: "Loading metadata…", + spatial: "Loading map tiles…", +}; + +const DOMAIN_HEIGHT: Record = { + blockchain: "h-[160px]", + telemetry: "h-[200px]", + metadata: "h-[120px]", + spatial: "h-[300px]", +}; + +export interface SkeletonProps { + domain: DomainKey; + className?: string; +} + +export function SkeletonLoader({ domain, className }: SkeletonProps) { + return ( +
+ {DOMAIN_LABEL[domain]} +
+ ); +} + +/** Composite skeleton (e.g. while a dashboard's children all suspend). */ +export function CompositeSkeleton({ domains }: { domains: DomainKey[] }) { + return ( +
+ {domains.map((d) => ( + + ))} +
+ ); +} diff --git a/src/components/boundaries/index.tsx b/src/components/boundaries/index.tsx new file mode 100644 index 0000000..2a3f03b --- /dev/null +++ b/src/components/boundaries/index.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { ReactNode } from "react"; +import { DomainBoundary } from "@/components/boundaries/DomainBoundary"; +import { SkeletonLoader, CompositeSkeleton } from "@/components/boundaries/Skeletons"; +import { CascadeProvider, useCascade } from "@/components/boundaries/CascadeContext"; + +/** + * Named per-domain boundaries (isolated) and the two composite boundaries + * (dashboard, map) that coordinate the fallback cascade. + */ + +export { ErrorBoundary } from "@/components/boundaries/ErrorBoundary"; +export { ErrorPanel } from "@/components/boundaries/ErrorPanel"; +export { SkeletonLoader, CompositeSkeleton } from "@/components/boundaries/Skeletons"; +export { DomainBoundary } from "@/components/boundaries/DomainBoundary"; +export { + CascadeProvider, + CascadeGate, + useCascade, +} from "@/components/boundaries/CascadeContext"; + +// --- Primary domain boundaries (standalone, error-isolated) ----------------- + +export function BlockchainBoundary({ children }: { children: ReactNode }) { + return ( + } + errorTitle="Contract state unavailable" + > + {children} + + ); +} + +export function TelemetryBoundary({ children }: { children: ReactNode }) { + return ( + } + errorTitle="Telemetry stream unavailable" + > + {children} + + ); +} + +export function MetadataBoundary({ children }: { children: ReactNode }) { + return ( + } + errorTitle="Metadata unavailable" + > + {children} + + ); +} + +export function SpatialBoundary({ children }: { children: ReactNode }) { + return ( + } + errorTitle="Map tiles unavailable" + > + {children} + + ); +} + +// --- Composite boundaries --------------------------------------------------- + +function DashboardBoundaryInner({ children }: { children: ReactNode }) { + const { block, reset } = useCascade(); + return ( + } + errorTitle="Dashboard data unavailable" + onError={block} + onRetry={reset} + > + {children} + + ); +} + +/** + * Composite of the blockchain + telemetry domains. A critical blockchain error + * surfaces a single dashboard error panel; because the shared boundary unmounts + * its subtree, the telemetry resource never fetches (and `useCascade().blocked` + * is set for any descendant that needs to know). + */ +export function DashboardBoundary({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** Composite of the metadata + spatial domains. */ +export function MapBoundary({ children }: { children: ReactNode }) { + return ( + } + errorTitle="Map data unavailable" + > + {children} + + ); +} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..1229337 --- /dev/null +++ b/src/components/dashboard/DashboardPage.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useMemo } from "react"; +import { + DashboardBoundary, + MapBoundary, +} from "@/components/boundaries"; +import { createResource } from "@/utils/suspenseResource"; +import { useSuspenseResource } from "@/hooks/useSuspenseResource"; +import { + CACHE_TTL_MS, + domainCacheKey, + type SuspenseResource, +} from "@/types/suspense"; + +/** + * Top-level page composing the Suspense boundaries with proper nesting: + * DashboardBoundary { blockchain + telemetry } (critical-cascade) + * MapBoundary { metadata + spatial } + * + * (The blueprint names `src/pages/Dashboard.tsx`, but this is an App Router + * project, so it ships as a component to avoid a Pages Router conflict.) + */ + +export interface DashboardData { + blockchain: SuspenseResource<{ ledger: number }>; + telemetry: SuspenseResource<{ readings: number }>; + metadata: SuspenseResource<{ tariffs: number }>; + spatial: SuspenseResource<{ tiles: number }>; +} + +export interface DashboardPageProps { + /** Inject resources (tests / real wiring); defaults are demo stubs. */ + resources?: DashboardData; +} + +function defaultResources(): DashboardData { + const stub = (domain: Parameters[0], value: T) => + createResource(() => Promise.resolve(value), { + cacheKey: domainCacheKey(domain, "summary"), + ttlMs: CACHE_TTL_MS[domain], + }); + return { + blockchain: stub("blockchain", { ledger: 0 }), + telemetry: stub("telemetry", { readings: 0 }), + metadata: stub("metadata", { tariffs: 0 }), + spatial: stub("spatial", { tiles: 0 }), + }; +} + +function Stat({ label, value }: { label: string; value: number | string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function BlockchainSection({ resource }: { resource: DashboardData["blockchain"] }) { + const data = useSuspenseResource(resource); + return ; +} +function TelemetrySection({ resource }: { resource: DashboardData["telemetry"] }) { + const data = useSuspenseResource(resource); + return ; +} +function MetadataSection({ resource }: { resource: DashboardData["metadata"] }) { + const data = useSuspenseResource(resource); + return ; +} +function SpatialSection({ resource }: { resource: DashboardData["spatial"] }) { + const data = useSuspenseResource(resource); + return ; +} + +export function DashboardPage({ resources }: DashboardPageProps) { + const data = useMemo(() => resources ?? defaultResources(), [resources]); + + return ( +
+ +
+ + +
+
+ + +
+ + +
+
+
+ ); +} + +export default DashboardPage; diff --git a/src/hooks/useSuspenseResource.ts b/src/hooks/useSuspenseResource.ts new file mode 100644 index 0000000..90d9e81 --- /dev/null +++ b/src/hooks/useSuspenseResource.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useCacheState } from "@/store/slices/cacheSlice"; +import type { SuspenseResource } from "@/types/suspense"; + +/** + * Read a Suspense resource in a component. Subscribes to the cache so a + * background stale-while-revalidate fetch re-renders the component with fresh + * data when it completes; `resource.read()` throws the pending promise (for the + * enclosing Suspense boundary) or the error (for the ErrorBoundary). + */ +export function useSuspenseResource(resource: SuspenseResource): T { + useCacheState(); // re-render on cache updates (SWR revalidation) + return resource.read(); +} diff --git a/src/services/soroban.ts b/src/services/soroban.ts index 2fd0b34..3520165 100644 --- a/src/services/soroban.ts +++ b/src/services/soroban.ts @@ -71,3 +71,22 @@ export const submitWithNonce = async ( // Successfully consumed. We return the response. return response; }; + +/** + * Promise-based read of a contract's persistent data entry, suitable for + * wrapping in a Suspense resource (see {@link createResource}). + */ +export const readContractState = async ( + contractId: string, + key: string, + network: string = "testnet" +): Promise => { + const { rpc, xdr, scValToNative } = await import("@stellar/stellar-sdk"); + const server = new rpc.Server(getRpcUrl(network)); + const entry = await server.getContractData( + contractId, + xdr.ScVal.scvSymbol(key), + rpc.Durability.Persistent + ); + return scValToNative(entry.val.contractData().val()) as T; +}; diff --git a/src/store/slices/cacheSlice.ts b/src/store/slices/cacheSlice.ts new file mode 100644 index 0000000..2e0dad9 --- /dev/null +++ b/src/store/slices/cacheSlice.ts @@ -0,0 +1,171 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + cacheGroupOf, + type CacheEntry, + type ResourceStatus, +} from "@/types/suspense"; + +/** + * Resource cache for the Suspense layer: per-key entries with a fetched-at + * timestamp and TTL, supporting stale-while-revalidate reads and group + * invalidation (a Retry button invalidates a whole domain group). + * + * Custom singleton store, matching the codebase pattern. + */ + +export type CacheState = Record; + +export type CacheAction = + | { type: "CACHE_PENDING"; payload: { key: string; ttlMs: number } } + | { + type: "CACHE_UPDATED"; + payload: { key: string; data: unknown; fetchedAt: number; ttlMs: number }; + } + | { type: "CACHE_REJECTED"; payload: { key: string; error: string; fetchedAt: number } } + | { type: "CACHE_INVALIDATE"; payload: { group: string } } + | { type: "CACHE_INVALIDATE_KEY"; payload: { key: string } } + | { type: "RESET" }; + +/** A resolved entry is fresh while within its TTL (and not invalidated). */ +export function isFresh(entry: CacheEntry | undefined, now: number): boolean { + return ( + !!entry && + entry.status === "resolved" && + entry.fetchedAt > 0 && + now - entry.fetchedAt <= entry.ttlMs + ); +} + +/** A resolved entry that has data but is past its TTL → serve + revalidate. */ +export function isStale(entry: CacheEntry | undefined, now: number): boolean { + return ( + !!entry && + entry.status === "resolved" && + entry.data !== undefined && + (entry.fetchedAt === 0 || now - entry.fetchedAt > entry.ttlMs) + ); +} + +type Listener = (state: CacheState) => void; + +class CacheStore { + private state: CacheState = {}; + private listeners = new Set(); + + getState = (): Readonly => this.state; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getEntry(key: string): CacheEntry | undefined { + return this.state[key]; + } + + dispatch(action: CacheAction): void { + const next = this.reducer(this.state, action); + if (next !== this.state) { + this.state = next; + this.notify(); + } + } + + // --- convenience mutators (used by the resource factory) ------------------ + + setPending(key: string, ttlMs: number): void { + this.dispatch({ type: "CACHE_PENDING", payload: { key, ttlMs } }); + } + setResolved(key: string, data: unknown, ttlMs: number, now: number): void { + this.dispatch({ type: "CACHE_UPDATED", payload: { key, data, fetchedAt: now, ttlMs } }); + } + setRejected(key: string, error: string, now: number): void { + this.dispatch({ type: "CACHE_REJECTED", payload: { key, error, fetchedAt: now } }); + } + invalidateGroup(group: string): void { + this.dispatch({ type: "CACHE_INVALIDATE", payload: { group } }); + } + invalidateKey(key: string): void { + this.dispatch({ type: "CACHE_INVALIDATE_KEY", payload: { key } }); + } + + private reducer(state: CacheState, action: CacheAction): CacheState { + switch (action.type) { + case "CACHE_PENDING": { + const prev = state[action.payload.key]; + // Keep any existing data so a re-fetch can still serve stale-while-revalidate. + const status: ResourceStatus = "pending"; + return { + ...state, + [action.payload.key]: { + data: prev?.data, + status, + fetchedAt: prev?.fetchedAt ?? 0, + ttlMs: action.payload.ttlMs, + }, + }; + } + case "CACHE_UPDATED": + return { + ...state, + [action.payload.key]: { + data: action.payload.data, + status: "resolved", + fetchedAt: action.payload.fetchedAt, + ttlMs: action.payload.ttlMs, + }, + }; + case "CACHE_REJECTED": { + const prev = state[action.payload.key]; + return { + ...state, + [action.payload.key]: { + data: prev?.data, + status: "rejected", + fetchedAt: action.payload.fetchedAt, + ttlMs: prev?.ttlMs ?? 0, + error: action.payload.error, + }, + }; + } + case "CACHE_INVALIDATE": { + const group = action.payload.group; + let changed = false; + const next: CacheState = { ...state }; + for (const [key, entry] of Object.entries(state)) { + if (cacheGroupOf(key) === group && entry.fetchedAt !== 0) { + next[key] = { ...entry, fetchedAt: 0 }; + changed = true; + } + } + return changed ? next : state; + } + case "CACHE_INVALIDATE_KEY": { + const entry = state[action.payload.key]; + if (!entry || entry.fetchedAt === 0) return state; + return { ...state, [action.payload.key]: { ...entry, fetchedAt: 0 } }; + } + case "RESET": + return {}; + default: + return state; + } + } + + private notify(): void { + for (const listener of this.listeners) listener(this.state); + } +} + +/** Shared singleton resource cache. */ +export const cacheStore = new CacheStore(); + +export function useCacheState(): CacheState { + return useSyncExternalStore( + cacheStore.subscribe, + cacheStore.getState, + cacheStore.getState + ); +} diff --git a/src/types/suspense.ts b/src/types/suspense.ts new file mode 100644 index 0000000..0849603 --- /dev/null +++ b/src/types/suspense.ts @@ -0,0 +1,72 @@ +/** + * Types and invariants for the Suspense boundary architecture. + * + * Four primary data domains (blockchain, telemetry, metadata, spatial) each get + * a named Suspense boundary with a stale-while-revalidate resource cache, and + * two composite boundaries (dashboard, map) coordinate a fallback cascade: when + * a parent boundary errors, its children render an error state instead of their + * own loaders. + */ + +export type DomainKey = "blockchain" | "telemetry" | "metadata" | "spatial"; + +export const DOMAINS: DomainKey[] = [ + "blockchain", + "telemetry", + "metadata", + "spatial", +]; + +/** Suspense fallback timeout per domain (ms). */ +export const SUSPENSE_TIMEOUT_MS: Record = { + blockchain: 10_000, // Soroban RPC can be slow + telemetry: 3_000, // WebSocket should be fast + metadata: 5_000, + spatial: 8_000, +}; + +/** Cache TTL per domain (ms) for the stale-while-revalidate window. */ +export const CACHE_TTL_MS: Record = { + blockchain: 30_000, + telemetry: 5_000, + metadata: 120_000, + spatial: 300_000, +}; + +/** Promise lifecycle of a Suspense resource. */ +export type ResourceStatus = "pending" | "resolved" | "rejected"; + +/** A cached resource entry. */ +export interface CacheEntry { + data?: T; + status: ResourceStatus; + /** When the data was fetched (unix ms); 0 means invalidated. */ + fetchedAt: number; + ttlMs: number; + error?: string; +} + +/** A Suspense-compatible resource handle. */ +export interface SuspenseResource { + /** Read for render: returns data, or throws a promise/error for Suspense. */ + read(): T; + /** Kick off a fetch without suspending. */ + prefetch(): void; + /** Force the next read to re-fetch. */ + invalidate(): void; + /** Non-throwing peek at the cached data, if any. */ + peek(): T | undefined; +} + +/** + * Cache keys are `":"`. The domain prefix is the invalidation group + * a Retry button targets. + */ +export function cacheGroupOf(cacheKey: string): string { + const idx = cacheKey.indexOf(":"); + return idx === -1 ? cacheKey : cacheKey.slice(0, idx); +} + +export function domainCacheKey(domain: DomainKey, id: string): string { + return `${domain}:${id}`; +} diff --git a/src/utils/suspenseResource.ts b/src/utils/suspenseResource.ts new file mode 100644 index 0000000..e5ffd93 --- /dev/null +++ b/src/utils/suspenseResource.ts @@ -0,0 +1,108 @@ +"use client"; + +import { cacheStore, isFresh } from "@/store/slices/cacheSlice"; +import { type SuspenseResource } from "@/types/suspense"; + +/** + * Factory for Suspense-compatible resources. + * + * `read()` implements the throw-promise pattern with stale-while-revalidate: + * - fresh cache → return data; + * - stale cache → return data *and* revalidate in the background (no suspend); + * - no usable data → throw the in-flight fetch promise (suspend); + * - rejected → throw the error (for an ErrorBoundary), until invalidated. + * + * In-flight fetches are de-duplicated per cache key. + */ + +interface CacheStoreLike { + getEntry(key: string): import("@/types/suspense").CacheEntry | undefined; + setPending(key: string, ttlMs: number): void; + setResolved(key: string, data: unknown, ttlMs: number, now: number): void; + setRejected(key: string, error: string, now: number): void; + invalidateKey(key: string): void; +} + +export interface CreateResourceOptions { + cacheKey: string; + ttlMs: number; + now?: () => number; + store?: CacheStoreLike; +} + +/** In-flight fetch promises, keyed by cache key (module-global de-dup). */ +const inflight = new Map>(); + +export function createResource( + fetchFn: () => Promise, + options: CreateResourceOptions +): SuspenseResource { + const { cacheKey, ttlMs } = options; + const now = options.now ?? Date.now; + const store = options.store ?? cacheStore; + + function runFetch(foreground: boolean): Promise { + const existing = inflight.get(cacheKey); + if (existing) return existing; + + if (foreground) store.setPending(cacheKey, ttlMs); + + const promise = fetchFn() + .then((data) => { + store.setResolved(cacheKey, data, ttlMs, now()); + }) + .catch((err: unknown) => { + store.setRejected( + cacheKey, + err instanceof Error ? err.message : String(err), + now() + ); + }) + .finally(() => { + inflight.delete(cacheKey); + }); + + inflight.set(cacheKey, promise); + return promise; + } + + return { + read(): T { + const entry = store.getEntry(cacheKey); + const t = now(); + + if (entry?.status === "rejected" && entry.fetchedAt !== 0) { + throw new Error(entry.error ?? `resource ${cacheKey} failed`); + } + + if (entry && entry.data !== undefined && entry.status !== "rejected") { + if (isFresh(entry, t)) return entry.data as T; + // Stale: serve cached data and revalidate in the background. + void runFetch(false); + return entry.data as T; + } + + // No usable data → suspend on the fetch promise. + throw runFetch(true); + }, + + prefetch(): void { + const entry = store.getEntry(cacheKey); + if (isFresh(entry, now())) return; + void runFetch(false); + }, + + invalidate(): void { + store.invalidateKey(cacheKey); + }, + + peek(): T | undefined { + return store.getEntry(cacheKey)?.data as T | undefined; + }, + }; +} + +/** Test/teardown helper: drop any in-flight promise tracking. */ +export function _clearInflight(): void { + inflight.clear(); +} diff --git a/tests/components/boundaries.test.tsx b/tests/components/boundaries.test.tsx new file mode 100644 index 0000000..7d037f6 --- /dev/null +++ b/tests/components/boundaries.test.tsx @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; +import { + DomainBoundary, + DashboardBoundary, + CascadeProvider, + CascadeGate, + useCascade, +} from "@/components/boundaries"; +import { createResource, _clearInflight } from "@/utils/suspenseResource"; +import { useSuspenseResource } from "@/hooks/useSuspenseResource"; +import { cacheStore } from "@/store/slices/cacheSlice"; +import type { SuspenseResource } from "@/types/suspense"; + +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function Value({ resource }: { resource: SuspenseResource<{ label: string }> }) { + const data = useSuspenseResource(resource); + return
{data.label}
; +} + +let counter = 0; +const key = (domain: string) => `${domain}:rtl${counter++}`; + +beforeEach(() => { + cacheStore.dispatch({ type: "RESET" }); + _clearInflight(); +}); + +describe("DomainBoundary", () => { + it("shows the fallback while suspended, then the content once resolved", async () => { + const d = deferred<{ label: string }>(); + const resource = createResource(() => d.promise, { + cacheKey: key("metadata"), + ttlMs: 5000, + }); + + render( + skeleton} errorTitle="err"> + + + ); + + expect(screen.getByText("skeleton")).toBeInTheDocument(); + + await act(async () => { + d.resolve({ label: "loaded!" }); + await d.promise; + }); + + await waitFor(() => expect(screen.getByText("loaded!")).toBeInTheDocument()); + }); + + it("renders an ErrorPanel on failure and recovers on Retry", async () => { + let attempt = 0; + const resource = createResource( + () => { + attempt += 1; + return attempt === 1 + ? Promise.reject(new Error("fail-once")) + : Promise.resolve({ label: "recovered" }); + }, + { cacheKey: key("metadata"), ttlMs: 5000 } + ); + + render( + skeleton} errorTitle="Metadata unavailable"> + + + ); + + await waitFor(() => expect(screen.getByText("Metadata unavailable")).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => expect(screen.getByText("recovered")).toBeInTheDocument()); + expect(attempt).toBe(2); + }); +}); + +describe("DashboardBoundary fallback cascade", () => { + it("surfaces a single dashboard error and the telemetry resource never fetches", async () => { + const blockchain = createResource(() => Promise.reject(new Error("chain down")), { + cacheKey: key("blockchain"), + ttlMs: 5000, + }); + const telemetry = createResource<{ label: string }>( + () => Promise.resolve({ label: "tele" }), + { cacheKey: key("telemetry"), ttlMs: 5000 } + ); + + render( + + + + + ); + + await waitFor(() => + expect(screen.getByText("Dashboard data unavailable")).toBeInTheDocument() + ); + + // The blockchain error cascaded to the single dashboard boundary: one error + // panel is shown and the telemetry subtree is not rendered (no own loader). + expect(screen.queryByText("tele")).not.toBeInTheDocument(); + expect(screen.queryByText("Telemetry stream unavailable")).not.toBeInTheDocument(); + }); +}); + +describe("CascadeGate", () => { + it("renders children until blocked, then the blocked view (skips the subtree)", () => { + function Harness() { + const { block } = useCascade(); + return ( + <> + + blocked-view}> +
active-view
+
+ + ); + } + + render( + + + + ); + + expect(screen.getByText("active-view")).toBeInTheDocument(); + expect(screen.queryByText("blocked-view")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "block" })); + + expect(screen.getByText("blocked-view")).toBeInTheDocument(); + expect(screen.queryByText("active-view")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/cacheSlice.test.ts b/tests/unit/cacheSlice.test.ts new file mode 100644 index 0000000..c6a728d --- /dev/null +++ b/tests/unit/cacheSlice.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { cacheStore, isFresh, isStale } from "@/store/slices/cacheSlice"; +import type { CacheEntry } from "@/types/suspense"; + +beforeEach(() => cacheStore.dispatch({ type: "RESET" })); + +const entry = (over: Partial = {}): CacheEntry => ({ + data: { v: 1 }, + status: "resolved", + fetchedAt: 1000, + ttlMs: 5000, + ...over, +}); + +describe("isFresh / isStale", () => { + it("is fresh within the TTL", () => { + expect(isFresh(entry({ fetchedAt: 1000, ttlMs: 5000 }), 2000)).toBe(true); + expect(isStale(entry({ fetchedAt: 1000, ttlMs: 5000 }), 2000)).toBe(false); + }); + + it("is stale past the TTL", () => { + expect(isFresh(entry({ fetchedAt: 1000, ttlMs: 5000 }), 7000)).toBe(false); + expect(isStale(entry({ fetchedAt: 1000, ttlMs: 5000 }), 7000)).toBe(true); + }); + + it("treats fetchedAt 0 (invalidated) as stale", () => { + expect(isFresh(entry({ fetchedAt: 0 }), 1000)).toBe(false); + expect(isStale(entry({ fetchedAt: 0 }), 1000)).toBe(true); + }); + + it("a pending entry without data is neither fresh nor stale", () => { + const e = entry({ status: "pending", data: undefined }); + expect(isFresh(e, 2000)).toBe(false); + expect(isStale(e, 2000)).toBe(false); + }); +}); + +describe("cacheStore mutations", () => { + it("stores resolved data with timestamp + ttl", () => { + cacheStore.setResolved("blockchain:x", { ledger: 9 }, 30_000, 5000); + const e = cacheStore.getEntry("blockchain:x")!; + expect(e.status).toBe("resolved"); + expect(e.data).toEqual({ ledger: 9 }); + expect(e.fetchedAt).toBe(5000); + }); + + it("records rejection while preserving any stale data", () => { + cacheStore.setResolved("metadata:y", { tariffs: 2 }, 1000, 1000); + cacheStore.setRejected("metadata:y", "boom", 6000); + const e = cacheStore.getEntry("metadata:y")!; + expect(e.status).toBe("rejected"); + expect(e.error).toBe("boom"); + expect(e.data).toEqual({ tariffs: 2 }); // kept for SWR + }); + + it("invalidates a whole group (sets fetchedAt to 0)", () => { + cacheStore.setResolved("blockchain:a", 1, 1000, 1000); + cacheStore.setResolved("blockchain:b", 2, 1000, 1000); + cacheStore.setResolved("telemetry:c", 3, 1000, 1000); + cacheStore.invalidateGroup("blockchain"); + expect(cacheStore.getEntry("blockchain:a")!.fetchedAt).toBe(0); + expect(cacheStore.getEntry("blockchain:b")!.fetchedAt).toBe(0); + expect(cacheStore.getEntry("telemetry:c")!.fetchedAt).toBe(1000); // untouched + }); + + it("invalidates a single key", () => { + cacheStore.setResolved("spatial:tile", 1, 1000, 1000); + cacheStore.invalidateKey("spatial:tile"); + expect(cacheStore.getEntry("spatial:tile")!.fetchedAt).toBe(0); + }); +}); diff --git a/tests/unit/suspenseResource.test.ts b/tests/unit/suspenseResource.test.ts new file mode 100644 index 0000000..c7c1d52 --- /dev/null +++ b/tests/unit/suspenseResource.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createResource, _clearInflight } from "@/utils/suspenseResource"; +import type { CacheEntry } from "@/types/suspense"; + +/** In-memory fake of the cache store the resource factory talks to. */ +function fakeStore() { + const m = new Map(); + return { + map: m, + getEntry: (k: string) => m.get(k), + setPending: (k: string, ttlMs: number) => { + const prev = m.get(k); + m.set(k, { data: prev?.data, status: "pending", fetchedAt: prev?.fetchedAt ?? 0, ttlMs }); + }, + setResolved: (k: string, data: unknown, ttlMs: number, now: number) => + m.set(k, { data, status: "resolved", fetchedAt: now, ttlMs }), + setRejected: (k: string, error: string, now: number) => { + const prev = m.get(k); + m.set(k, { data: prev?.data, status: "rejected", fetchedAt: now, ttlMs: prev?.ttlMs ?? 0, error }); + }, + invalidateKey: (k: string) => { + const e = m.get(k); + if (e) m.set(k, { ...e, fetchedAt: 0 }); + }, + }; +} + +let keyCounter = 0; +const nextKey = () => `blockchain:r${keyCounter++}`; + +beforeEach(() => _clearInflight()); + +describe("createResource.read", () => { + it("throws the fetch promise while pending, then returns data once resolved", async () => { + const store = fakeStore(); + const key = nextKey(); + const fetchFn = vi.fn().mockResolvedValue({ ledger: 7 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 1000, store }); + + let thrown: unknown; + try { + resource.read(); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(Promise); + await thrown; + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(resource.read()).toEqual({ ledger: 7 }); + }); + + it("returns cached data without re-fetching while fresh", () => { + const store = fakeStore(); + const key = nextKey(); + store.setResolved(key, { v: 1 }, 5000, 1000); + const fetchFn = vi.fn().mockResolvedValue({ v: 2 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 2000, store }); + + expect(resource.read()).toEqual({ v: 1 }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("serves stale data immediately and revalidates in the background (SWR)", async () => { + const store = fakeStore(); + const key = nextKey(); + store.setResolved(key, { v: 1 }, 5000, 1000); // fetched at t=1000 + const fetchFn = vi.fn().mockResolvedValue({ v: 2 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 10_000, store }); + + // Past TTL → returns cached value, does NOT throw, kicks a background fetch. + expect(resource.read()).toEqual({ v: 1 }); + expect(fetchFn).toHaveBeenCalledTimes(1); + + await Promise.resolve(); + await Promise.resolve(); + expect(store.getEntry(key)!.data).toEqual({ v: 2 }); // revalidated + }); + + it("throws the error for a rejected resource", () => { + const store = fakeStore(); + const key = nextKey(); + store.setRejected(key, "rpc down", 1000); + const resource = createResource(vi.fn(), { cacheKey: key, ttlMs: 5000, now: () => 2000, store }); + expect(() => resource.read()).toThrow("rpc down"); + }); + + it("invalidate forces the next read to re-fetch", async () => { + const store = fakeStore(); + const key = nextKey(); + store.setResolved(key, { v: 1 }, 5000, 1000); + const fetchFn = vi.fn().mockResolvedValue({ v: 2 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 1500, store }); + + expect(resource.read()).toEqual({ v: 1 }); // fresh, no fetch + expect(fetchFn).not.toHaveBeenCalled(); + + resource.invalidate(); + expect(resource.read()).toEqual({ v: 1 }); // stale → serves cached + bg fetch + expect(fetchFn).toHaveBeenCalledTimes(1); + await Promise.resolve(); + await Promise.resolve(); + expect(store.getEntry(key)!.data).toEqual({ v: 2 }); + }); + + it("de-duplicates concurrent fetches for the same key", () => { + const store = fakeStore(); + const key = nextKey(); + const fetchFn = vi.fn().mockResolvedValue({ v: 1 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 1000, store }); + try { resource.read(); } catch { /* suspends */ } + try { resource.read(); } catch { /* suspends again, same in-flight */ } + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("createResource.prefetch / peek", () => { + it("prefetch warms the cache without throwing", async () => { + const store = fakeStore(); + const key = nextKey(); + const fetchFn = vi.fn().mockResolvedValue({ v: 9 }); + const resource = createResource(fetchFn, { cacheKey: key, ttlMs: 5000, now: () => 1000, store }); + resource.prefetch(); + expect(fetchFn).toHaveBeenCalledTimes(1); + await Promise.resolve(); + await Promise.resolve(); + expect(resource.peek()).toEqual({ v: 9 }); + }); +});