From 3f2ff47c94a7fd24af43cd9737dad74d54d8b877 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 28 Apr 2026 10:30:22 -0400 Subject: [PATCH] refactor(frontend): extract useImageUpdates hook from EditorLayout (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditorLayout owned the stack-image-update state plus a 5-minute polling interval as part of a 30-line useEffect that already juggled six other concerns (selected file, active view, stacks refresh, auto-update settings, git-source pending, …). The image- update slice has clean boundaries: it depends on activeNode.id, mutates one state object, and is otherwise unrelated to the rest of the effect. Move it into a dedicated hook at frontend/src/hooks/useImageUpdates.ts. The hook owns the stackUpdates state, runs an initial fetch on activeNode.id change, schedules the 5-minute poll, and exposes a refresh() callback for the four manual-trigger sites (deploy success, image-update action, manual registry-refresh poll). The hook destructure aliases refresh to fetchImageUpdates so existing call sites in EditorLayout don't need to be renamed. This is the first slice of audit finding 1.6 (EditorLayout 3129-line refactor); the next slice is useFleetNotifications. --- frontend/src/components/EditorLayout.tsx | 22 +++--------- frontend/src/hooks/useImageUpdates.ts | 44 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 frontend/src/hooks/useImageUpdates.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 4fbb1da2..4a761179 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo, useCallback, Suspense } from 'rea type Theme = 'light' | 'dark' | 'auto'; import { Editor } from '@/lib/monacoLoader'; +import { useImageUpdates } from '@/hooks/useImageUpdates'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; @@ -354,7 +355,7 @@ export default function EditorLayout() { // Image update checker state - const [stackUpdates, setStackUpdates] = useState>({}); + const { stackUpdates, refresh: fetchImageUpdates } = useImageUpdates(activeNode?.id); const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); const isAdmiral = license?.variant === 'admiral'; @@ -824,13 +825,10 @@ export default function EditorLayout() { } refreshStacks(); - fetchImageUpdates(); + // Image-update fetching + 5-minute poll are owned by useImageUpdates, + // which mirrors this effect's activeNode.id dependency. fetchAutoUpdateSettings(); refreshGitSourcePending(); - - // Poll for image update results every 5 minutes so background checks are picked up - const imageUpdateInterval = setInterval(fetchImageUpdates, 5 * 60 * 1000); - return () => clearInterval(imageUpdateInterval); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps const fetchNotifications = async () => { @@ -878,18 +876,6 @@ export default function EditorLayout() { return () => clearInterval(id); }, []); - const fetchImageUpdates = async () => { - try { - const res = await apiFetch('/image-updates'); - if (res.ok) { - const data = await res.json(); - setStackUpdates(data); - } - } catch (e: unknown) { - console.error('[ImageUpdates] fetch failed:', e); - } - }; - const fetchAutoUpdateSettings = async () => { try { const res = await apiFetch('/stacks/auto-update-settings'); diff --git a/frontend/src/hooks/useImageUpdates.ts b/frontend/src/hooks/useImageUpdates.ts new file mode 100644 index 00000000..a2aa1ee5 --- /dev/null +++ b/frontend/src/hooks/useImageUpdates.ts @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { apiFetch } from '@/lib/api'; + +const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000; + +/** + * Owns the stack-image-update state and its 5-minute background poll. + * Re-fetches whenever `activeNodeId` changes; consumers can also call + * `refresh()` to force a refetch (e.g. after a deploy or a manual + * registry-check trigger). + * + * Extracted from EditorLayout so the polling lifecycle and its state + * live next to each other instead of being spread across a 3000-line + * component. The dependency on `apiFetch` keeps the call routed + * through the active-node header just like before. + */ +export function useImageUpdates(activeNodeId: number | undefined) { + const [stackUpdates, setStackUpdates] = useState>({}); + + const refresh = useCallback(async () => { + try { + const res = await apiFetch('/image-updates'); + if (res.ok) { + const data = await res.json() as Record; + setStackUpdates(data); + } + } catch (e: unknown) { + console.error('[ImageUpdates] fetch failed:', e); + } + }, []); + + // Pin the interval to the latest closure without retriggering it on + // every render the way putting `refresh` into the deps array would. + const refreshRef = useRef(refresh); + refreshRef.current = refresh; + + useEffect(() => { + void refreshRef.current(); + const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS); + return () => clearInterval(id); + }, [activeNodeId]); + + return { stackUpdates, refresh }; +}