// Copyright (c) 2026 Patrick Motsch // All rights reserved. /** * useTreeExpansion - fire-and-forget persistence for tree expand state. * * Simple contract: * - On mount: load saved expandedIds from backend (or null if none). * - Returns the loaded ids (once) so the tree can seed its initial state. * - Provides a `save(ids)` function that debounce-PUTs to the backend. * - No bidirectional state flow, no props, no re-render triggers. */ import { useCallback, useEffect, useRef, useState } from 'react'; import api from '../api'; const _SAVE_DEBOUNCE_MS = 600; export interface UseTreeExpansionResult { loaded: boolean; initialIds: string[] | null; save: (ids: string[]) => void; } export function useTreeExpansion( instanceId: string | null | undefined, scope: string, ): UseTreeExpansionResult { const [loaded, setLoaded] = useState(false); const [initialIds, setInitialIds] = useState(null); const saveTimerRef = useRef | null>(null); const latestRef = useRef(null); useEffect(() => { if (!instanceId) { setLoaded(true); setInitialIds(null); return; } let cancelled = false; setLoaded(false); api .get(`/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`) .then((res) => { if (cancelled) return; const fromServer: string[] | null = res.data?.expandedNodes ?? null; setInitialIds(fromServer); latestRef.current = fromServer; setLoaded(true); }) .catch((err) => { if (cancelled) return; console.warn('[useTreeExpansion] load failed', err); setInitialIds(null); setLoaded(true); }); return () => { cancelled = true; }; }, [instanceId, scope]); const save = useCallback( (ids: string[]) => { if (!instanceId) return; const sorted = [...ids].sort().join('|'); const prevSorted = latestRef.current ? [...latestRef.current].sort().join('|') : null; if (sorted === prevSorted) return; latestRef.current = ids; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { api .put( `/api/workspace/${instanceId}/ui-tree-expansion/${encodeURIComponent(scope)}`, { expandedNodes: latestRef.current ?? [] }, ) .catch((err) => { console.warn('[useTreeExpansion] save failed', err); }); }, _SAVE_DEBOUNCE_MS); }, [instanceId, scope], ); useEffect(() => { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; }, []); return { loaded, initialIds, save }; }