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 }; +}