summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks/useDirectives.ts
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/hooks/useDirectives.ts')
-rw-r--r--makima/frontend/src/hooks/useDirectives.ts178
1 files changed, 152 insertions, 26 deletions
diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts
index 898f671..8104de0 100644
--- a/makima/frontend/src/hooks/useDirectives.ts
+++ b/makima/frontend/src/hooks/useDirectives.ts
@@ -24,28 +24,111 @@ import {
createDirectivePR,
} from "../lib/api";
+// =============================================================================
+// Stale-while-revalidate cache
+//
+// Switching between directives in the document-mode sidebar used to feel
+// noticeably laggy because every navigation re-fired the GET request and
+// blocked the UI on it. We now keep a process-wide cache (mirrors the
+// pattern in `useUserSettings.ts`) and use the cache as the immediate
+// render value; a fresh fetch fires in the background and notifies all
+// subscribed hook instances when it lands.
+//
+// Mutations (start/pause/etc.) push their result back into the cache so
+// successive reads are also instant. Hard `refresh()` calls bypass the
+// cache age check and refetch.
+// =============================================================================
+
+let listCache: DirectiveSummary[] | null = null;
+let listInflight: Promise<DirectiveSummary[]> | null = null;
+const listSubscribers = new Set<(d: DirectiveSummary[]) => void>();
+
+const detailCache = new Map<string, DirectiveWithSteps>();
+const detailInflight = new Map<string, Promise<DirectiveWithSteps>>();
+const detailSubscribers = new Map<string, Set<(d: DirectiveWithSteps) => void>>();
+
+function notifyList(value: DirectiveSummary[]) {
+ for (const sub of listSubscribers) sub(value);
+}
+
+function notifyDetail(id: string, value: DirectiveWithSteps) {
+ const subs = detailSubscribers.get(id);
+ if (!subs) return;
+ for (const sub of subs) sub(value);
+}
+
+function fetchList(): Promise<DirectiveSummary[]> {
+ if (listInflight) return listInflight;
+ listInflight = listDirectives()
+ .then((res) => {
+ listCache = res.directives;
+ notifyList(res.directives);
+ return res.directives;
+ })
+ .finally(() => {
+ listInflight = null;
+ });
+ return listInflight;
+}
+
+function fetchDetail(id: string): Promise<DirectiveWithSteps> {
+ const existing = detailInflight.get(id);
+ if (existing) return existing;
+ const p = getDirective(id)
+ .then((d) => {
+ detailCache.set(id, d);
+ notifyDetail(id, d);
+ return d;
+ })
+ .finally(() => {
+ detailInflight.delete(id);
+ });
+ detailInflight.set(id, p);
+ return p;
+}
+
export function useDirectives() {
- const [directives, setDirectives] = useState<DirectiveSummary[]>([]);
- const [loading, setLoading] = useState(true);
+ const [directives, setDirectives] = useState<DirectiveSummary[]>(
+ () => listCache ?? [],
+ );
+ const [loading, setLoading] = useState<boolean>(listCache === null);
const [error, setError] = useState<string | null>(null);
+ useEffect(() => {
+ let mounted = true;
+ const sub = (value: DirectiveSummary[]) => {
+ if (!mounted) return;
+ setDirectives(value);
+ setLoading(false);
+ };
+ listSubscribers.add(sub);
+
+ // Always kick a background fetch on mount so we don't ship stale data;
+ // subscribers see the new value when it lands. UI shows the cached
+ // value immediately if there is one.
+ fetchList().catch((e) => {
+ if (!mounted) return;
+ setError(e instanceof Error ? e.message : "Failed to load directives");
+ setLoading(false);
+ });
+
+ return () => {
+ mounted = false;
+ listSubscribers.delete(sub);
+ };
+ }, []);
+
const refresh = useCallback(async () => {
try {
- setLoading(true);
- setError(null);
const res = await listDirectives();
- setDirectives(res.directives);
+ listCache = res.directives;
+ notifyList(res.directives);
+ setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load directives");
- } finally {
- setLoading(false);
}
}, []);
- useEffect(() => {
- refresh();
- }, [refresh]);
-
const create = useCallback(async (req: CreateDirectiveRequest) => {
const d = await createDirective(req);
await refresh();
@@ -54,6 +137,7 @@ export function useDirectives() {
const remove = useCallback(async (id: string) => {
await deleteDirective(id);
+ detailCache.delete(id);
await refresh();
}, [refresh]);
@@ -61,8 +145,12 @@ export function useDirectives() {
}
export function useDirective(id: string | undefined) {
- const [directive, setDirective] = useState<DirectiveWithSteps | null>(null);
- const [loading, setLoading] = useState(true);
+ const [directive, setDirective] = useState<DirectiveWithSteps | null>(
+ () => (id ? detailCache.get(id) ?? null : null),
+ );
+ const [loading, setLoading] = useState<boolean>(
+ id !== undefined && !detailCache.has(id),
+ );
const [error, setError] = useState<string | null>(null);
// Silently refresh without setting loading state (for polls)
@@ -70,35 +158,73 @@ export function useDirective(id: string | undefined) {
if (!id) return;
try {
const d = await getDirective(id);
- setDirective(d);
+ detailCache.set(id, d);
+ notifyDetail(id, d);
setError(null);
- } catch (e) {
+ } catch {
// Don't overwrite existing data on poll failure
}
}, [id]);
- // Full refresh with loading state (for initial load / explicit refresh)
+ // Full refresh with loading state (for explicit refresh)
const refresh = useCallback(async () => {
if (!id) return;
try {
- setLoading(true);
setError(null);
const d = await getDirective(id);
- setDirective(d);
+ detailCache.set(id, d);
+ notifyDetail(id, d);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load directive");
- } finally {
- setLoading(false);
}
}, [id]);
- // Reset state and fetch when ID changes
+ // Subscribe to detail updates for this id; render cached value
+ // immediately and kick a background fetch if the cache is missing.
useEffect(() => {
- setDirective(null);
- setError(null);
- setLoading(true);
- refresh();
- }, [id]); // eslint-disable-line react-hooks/exhaustive-deps
+ if (!id) {
+ setDirective(null);
+ setLoading(false);
+ setError(null);
+ return;
+ }
+
+ let mounted = true;
+ const sub = (value: DirectiveWithSteps) => {
+ if (!mounted) return;
+ setDirective(value);
+ setLoading(false);
+ };
+
+ let subs = detailSubscribers.get(id);
+ if (!subs) {
+ subs = new Set();
+ detailSubscribers.set(id, subs);
+ }
+ subs.add(sub);
+
+ const cached = detailCache.get(id);
+ if (cached) {
+ setDirective(cached);
+ setLoading(false);
+ } else {
+ setDirective(null);
+ setLoading(true);
+ }
+
+ // Always kick a fresh fetch so polling-driven UIs see updates.
+ fetchDetail(id).catch((e) => {
+ if (!mounted) return;
+ setError(e instanceof Error ? e.message : "Failed to load directive");
+ setLoading(false);
+ });
+
+ return () => {
+ mounted = false;
+ subs!.delete(sub);
+ if (subs!.size === 0) detailSubscribers.delete(id);
+ };
+ }, [id]);
// Auto-poll while directive is active, has an orchestrator task, or has a completion task
useEffect(() => {