'use client'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { createChildCache, type ChildCache } from '../../data/childCache'; import type { TreeItemId, TreeLoadChildren, TreeNode, } from '../../types'; import { collectAllFolderIds } from './collect-ids'; export interface UseAsyncChildrenOptions { data: TreeNode[]; loadChildren?: TreeLoadChildren; /** Current set of expanded ids — controls which async fetches fire. */ expanded: ReadonlySet; /** Bumps every time the cache mutates — invalidates downstream memos. */ cacheTick: number; /** Dispatch into the reducer (used to bump `cacheTick`). */ bumpCacheTick: () => void; } export interface UseAsyncChildrenReturn { /** Live async-children cache (mutable ref, not React state). */ cache: ChildCache; /** Id → node lookup across the root tree + cached async children. */ nodeById: Map>; /** Fetch a single folder's children (deduped, idempotent). */ fetchChildren: (node: TreeNode) => Promise | void; /** Drop one node's cache entry and refetch. */ refresh: (id: TreeItemId) => Promise; /** Clear the entire cache and refetch every currently-expanded folder. */ refreshAll: () => Promise; /** Collect every folder id known to Tree (root + cached). For `expandAll`. */ collectFolderIds: () => TreeItemId[]; } /** * Manage async children: a long-lived cache (in a ref), dedup-ed * `loadChildren` calls, and a `nodeById` map that walks both inline * children and cached async ones. The cache lives outside reducer state * because mutating a Map per render is cheaper than spreading it; we * trigger downstream re-renders via the `cacheTick` counter. */ export function useAsyncChildren({ data, loadChildren, expanded, cacheTick, bumpCacheTick, }: UseAsyncChildrenOptions): UseAsyncChildrenReturn { // The cache survives provider re-renders and async work. const cacheRef = useRef>(createChildCache()); const inflightRef = useRef>>(new Map()); const fetchChildren = useCallback( async (node: TreeNode) => { if (!loadChildren) return; if (Array.isArray(node.children)) return; const existing = cacheRef.current.get(node.id); if (existing?.status === 'loaded' || existing?.status === 'loading') return; const inflight = inflightRef.current.get(node.id); if (inflight) return inflight; cacheRef.current.set(node.id, { status: 'loading', children: [] }); bumpCacheTick(); const promise = (async () => { try { const children = await loadChildren(node); cacheRef.current.set(node.id, { status: 'loaded', children }); } catch (err) { cacheRef.current.set(node.id, { status: 'error', children: [], error: err instanceof Error ? err.message : String(err), }); } finally { inflightRef.current.delete(node.id); bumpCacheTick(); } })(); inflightRef.current.set(node.id, promise); return promise; }, [loadChildren, bumpCacheTick], ); // Build a quick id → node map across roots + cached async children. // Re-runs whenever the cache mutates (cacheTick) or `data` changes. const nodeById = useMemo(() => { const map = new Map>(); const walk = (nodes: TreeNode[]) => { for (const n of nodes) { map.set(n.id, n); if (Array.isArray(n.children)) walk(n.children); else { const entry = cacheRef.current.get(n.id); if (entry?.children) walk(entry.children); } } }; walk(data); return map; }, [data, cacheTick]); // Auto-fetch every expanded folder that isn't already in cache. Cheap // — `fetchChildren` short-circuits when cached or already inflight. useEffect(() => { if (!loadChildren) return; for (const id of expanded) { const node = nodeById.get(id); if (!node) continue; void fetchChildren(node); } }, [loadChildren, expanded, cacheTick, nodeById, fetchChildren]); const refresh = useCallback( async (id: TreeItemId) => { const node = nodeById.get(id); if (!node || !loadChildren) return; cacheRef.current.delete(id); bumpCacheTick(); await fetchChildren(node); }, [nodeById, loadChildren, fetchChildren, bumpCacheTick], ); const refreshAll = useCallback(async () => { cacheRef.current.clear(); bumpCacheTick(); if (!loadChildren) return; await Promise.all( [...expanded].map((id) => { const node = nodeById.get(id); return node ? fetchChildren(node) : undefined; }), ); }, [loadChildren, expanded, nodeById, fetchChildren, bumpCacheTick]); const collectFolderIds = useCallback(() => { const ids: TreeItemId[] = []; collectAllFolderIds(data, cacheRef.current, ids); return ids; }, [data]); return { cache: cacheRef.current, nodeById, fetchChildren, refresh, refreshAll, collectFolderIds, }; }