From d936ba82ec34d6ee28b69bc9f8dcdb5ca80c7c4d Mon Sep 17 00:00:00 2001 From: Fabro Date: Wed, 27 May 2026 02:47:06 +0000 Subject: [PATCH] fabro(01KSKJW0VNWJ55XBV4RPWP32R8): work (succeeded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabro-Run: 01KSKJW0VNWJ55XBV4RPWP32R8 Fabro-Completed: 2 Fabro-Checkpoint: 25ae49aa10de6b72a967cd9a8f7ec50e34fb5665 ⚒️ Generated with [Fabro](https://fabro.sh) --- apps/fabro-web/app/components/event-debug.tsx | 19 +- .../app/components/run-waterfall.tsx | 14 +- .../runs-list/selection-checkbox.tsx | 10 +- .../app/components/terminal-view.tsx | 122 +++++----- apps/fabro-web/app/components/toast.tsx | 6 +- apps/fabro-web/app/components/ui.tsx | 6 +- .../app/hooks/use-debounced-value.ts | 16 ++ .../fabro-web/app/hooks/use-document-title.ts | 15 ++ .../app/hooks/use-dot-language-ready.ts | 29 +++ apps/fabro-web/app/hooks/use-interval.ts | 25 ++ apps/fabro-web/app/hooks/use-media-query.ts | 24 ++ apps/fabro-web/app/hooks/use-mount-effect.ts | 16 ++ .../app/hooks/use-resize-observer.ts | 30 +++ apps/fabro-web/app/hooks/use-window-event.ts | 28 +++ apps/fabro-web/app/lib/live-events.ts | 19 ++ .../app/routes/automation-definition.tsx | 15 +- .../app/routes/automation-diagram.tsx | 46 ++-- apps/fabro-web/app/routes/chats-detail.tsx | 23 +- apps/fabro-web/app/routes/insights-editor.tsx | 26 +-- apps/fabro-web/app/routes/redirect-home.tsx | 19 +- apps/fabro-web/app/routes/run-artifacts.tsx | 10 +- apps/fabro-web/app/routes/run-children.tsx | 42 ++-- apps/fabro-web/app/routes/run-detail.tsx | 4 +- .../app/routes/run-detail/docked-controls.tsx | 24 +- apps/fabro-web/app/routes/run-detail/model.ts | 11 - apps/fabro-web/app/routes/run-files.tsx | 214 +++++++++++------- .../routes/run-files/file-tree-sidebar.tsx | 109 ++++++--- apps/fabro-web/app/routes/run-overview.tsx | 143 +++++++----- .../routes/run-sandbox/filesystem-panel.tsx | 9 +- apps/fabro-web/app/routes/run-source.tsx | 16 +- apps/fabro-web/app/routes/run-terminal.tsx | 11 +- apps/fabro-web/app/routes/runs.tsx | 17 +- .../app/routes/runs/workspace-preferences.ts | 18 +- .../app/routes/settings-live-events.tsx | 12 +- apps/fabro-web/app/routes/settings-models.tsx | 12 +- apps/fabro-web/app/routes/start.tsx | 7 +- 36 files changed, 739 insertions(+), 428 deletions(-) create mode 100644 apps/fabro-web/app/hooks/use-debounced-value.ts create mode 100644 apps/fabro-web/app/hooks/use-document-title.ts create mode 100644 apps/fabro-web/app/hooks/use-dot-language-ready.ts create mode 100644 apps/fabro-web/app/hooks/use-interval.ts create mode 100644 apps/fabro-web/app/hooks/use-media-query.ts create mode 100644 apps/fabro-web/app/hooks/use-mount-effect.ts create mode 100644 apps/fabro-web/app/hooks/use-resize-observer.ts create mode 100644 apps/fabro-web/app/hooks/use-window-event.ts diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index 1a1436264..bc56f15b4 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useWindowEvent } from "../hooks/use-window-event"; import { createPortal } from "react-dom"; import { Listbox, @@ -77,16 +78,12 @@ export function DetailsPanel({ onClose: () => void; children: React.ReactNode; }) { - // react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet. - useEffect(() => { - if (!isOpen) return; - function handleKey(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - // react-doctor-disable-next-line react-doctor/prefer-use-effect-event -- React's useEffectEvent is not in the installed React type surface yet. - }, [isOpen, onClose]); + useWindowEvent( + "keydown", + (event) => { if (event.key === "Escape") onClose(); }, + undefined, + isOpen, + ); return (
Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return now; -} - function stageBarClass(status: StageState): string { switch (status) { case StageState.RUNNING: @@ -194,7 +186,7 @@ export function RunWaterfall({ createdAtIso, completedAtIso, }: WaterfallProps) { - const nowMs = useTickingNow(1000); + const nowMs = useTickingNow(true, 1000); const rows = useMemo( () => buildRows({ runId, events, stages, createdAtIso, nowMs }), [runId, events, stages, createdAtIso, nowMs], diff --git a/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx b/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx index 7b7b1649d..b8dd81de6 100644 --- a/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx +++ b/apps/fabro-web/app/components/runs-list/selection-checkbox.tsx @@ -1,4 +1,6 @@ -import { useEffect, useRef } from "react"; +// `indeterminate` is an HTMLInputElement imperative property that cannot be +// set via an HTML attribute. We use a ref callback that React 19 calls on +// every render, ensuring the property stays in sync with the prop. export function SelectionCheckbox({ checked, @@ -13,13 +15,9 @@ export function SelectionCheckbox({ onChange: () => void; ariaLabel: string; }) { - const ref = useRef(null); - useEffect(() => { - if (ref.current) ref.current.indeterminate = indeterminate; - }, [indeterminate]); return ( { if (el) el.indeterminate = indeterminate; }} type="checkbox" aria-label={ariaLabel} checked={checked} diff --git a/apps/fabro-web/app/components/terminal-view.tsx b/apps/fabro-web/app/components/terminal-view.tsx index eed8b4d8c..7d6d2c73c 100644 --- a/apps/fabro-web/app/components/terminal-view.tsx +++ b/apps/fabro-web/app/components/terminal-view.tsx @@ -6,7 +6,6 @@ import { useState, } from "react"; import type { Terminal as XtermTerminal } from "@xterm/xterm"; -import type { FitAddon as XtermFitAddon } from "@xterm/addon-fit"; import { ArrowPathIcon, ArrowTopRightOnSquareIcon, @@ -140,55 +139,22 @@ function StatusPill({ ); } -export default function TerminalView({ - runId, - leading, - chromeless = false, -}: { - runId: string; - leading?: React.ReactNode; - chromeless?: boolean; -}) { - const { push } = useToast(); - const stateQuery = useRunState(runId); - const sandbox = stateQuery.data?.sandbox ?? null; - const provider = sandbox?.provider ?? null; - const sandboxDetail = sandboxStatusDetail(sandbox); - const accessCommandLabel = terminalAccessCommandLabel(provider); - const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0); - const [status, setStatus] = useState("connecting"); - const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null); - const terminalEl = useRef(null); - const terminalRef = useRef(null); - const fitRef = useRef(null); - const socketRef = useRef(null); - const headingId = `run-terminal-${runId}`; - - const reconnect = useCallback(() => { - setError(null); - setStatus("connecting"); - reconnectTerminal(); - }, []); - - const copyAccessCommand = useCallback(async () => { - if (!accessCommandLabel) return; - try { - const response = await apiData(() => - humanInTheLoopApi.createRunSshAccess(runId, { ttl_minutes: 60 }), - ); - await navigator.clipboard.writeText(response.command); - push({ message: terminalAccessCommandCopiedMessage(provider) }); - } catch (err) { - push({ - tone: "error", - message: err instanceof Error - ? err.message - : terminalAccessCommandErrorMessage(provider), - }); - } - }, [accessCommandLabel, runId, provider, push]); - - // react-doctor-disable-next-line react-doctor/effect-needs-cleanup -- listeners, socket, xterm, and ResizeObserver are disposed in the returned cleanup. +/** + * Creates and manages an xterm.js Terminal + WebSocket session for the given + * run. A new session is established each time `connectionKey` increments. + * + * External systems: xterm.js (dynamic ESM import), WebSocket, ResizeObserver, + * and the browser `document.fonts.ready` promise. + * Cleanup: disconnects ResizeObserver, disposes xterm disposables, closes the + * WebSocket gracefully, and disposes the terminal instance. + */ +function useTerminalSession( + runId: string, + connectionKey: number, + terminalEl: React.RefObject, + setStatus: React.Dispatch>, + setError: React.Dispatch>, +): void { useEffect(() => { if (!terminalEl.current) return undefined; @@ -196,6 +162,8 @@ export default function TerminalView({ let resizeObserver: ResizeObserver | null = null; const textEncoder = new TextEncoder(); const disposables: Array<{ dispose: () => void }> = []; + const terminalRef: { current: XtermTerminal | null } = { current: null }; + const socketRef: { current: WebSocket | null } = { current: null }; async function connect() { setStatus("connecting"); @@ -222,7 +190,6 @@ export default function TerminalView({ fitAddon.fit(); terminal.focus(); terminalRef.current = terminal; - fitRef.current = fitAddon; const socket = new WebSocket(buildTerminalWebSocketUrl(window.location, runId)); socket.binaryType = "arraybuffer"; @@ -262,7 +229,7 @@ export default function TerminalView({ terminal.write(bytes); }; const handleClose = () => { - setStatus((current) => current === "error" ? current : "closed"); + setStatus((current: ConnectionStatus) => current === "error" ? current : "closed"); }; const handleError = () => { setStatus("error"); @@ -310,9 +277,56 @@ export default function TerminalView({ socketRef.current = null; terminalRef.current?.dispose(); terminalRef.current = null; - fitRef.current = null; }; - }, [connectionKey, runId]); + }, [connectionKey, runId, terminalEl, setStatus, setError]); +} + +export default function TerminalView({ + runId, + leading, + chromeless = false, +}: { + runId: string; + leading?: React.ReactNode; + chromeless?: boolean; +}) { + const { push } = useToast(); + const stateQuery = useRunState(runId); + const sandbox = stateQuery.data?.sandbox ?? null; + const provider = sandbox?.provider ?? null; + const sandboxDetail = sandboxStatusDetail(sandbox); + const accessCommandLabel = terminalAccessCommandLabel(provider); + const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0); + const [status, setStatus] = useState("connecting"); + const [error, setError] = useState<{ message: string; recoverable: boolean } | null>(null); + const terminalEl = useRef(null); + const headingId = `run-terminal-${runId}`; + + const reconnect = useCallback(() => { + setError(null); + setStatus("connecting"); + reconnectTerminal(); + }, []); + + const copyAccessCommand = useCallback(async () => { + if (!accessCommandLabel) return; + try { + const response = await apiData(() => + humanInTheLoopApi.createRunSshAccess(runId, { ttl_minutes: 60 }), + ); + await navigator.clipboard.writeText(response.command); + push({ message: terminalAccessCommandCopiedMessage(provider) }); + } catch (err) { + push({ + tone: "error", + message: err instanceof Error + ? err.message + : terminalAccessCommandErrorMessage(provider), + }); + } + }, [accessCommandLabel, runId, provider, push]); + + useTerminalSession(runId, connectionKey, terminalEl, setStatus, setError); return (
clear, [clear]); + // Clear all pending auto-dismiss timers when the provider unmounts so they + // cannot call setToasts on an unmounted component. + useMountEffect(() => clear); const value = useMemo(() => ({ push, dismiss, clear }), [push, dismiss, clear]); diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index fac25dcf4..85e6cce91 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -2,7 +2,8 @@ // exposes the primary button, secondary button, input, error message, and // copy button so the auth and in-app surfaces can match. -import { useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { useId, useRef, useState, type ReactNode } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; import { createPortal } from "react-dom"; import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; import { @@ -165,7 +166,8 @@ function useHoverAnchor(openDelay = 0) { setOpen(false); }; - useEffect(() => clearTimer, []); + // Cancel any pending open-delay timer when the anchor unmounts. + useMountEffect(() => clearTimer); const rect = open ? (triggerRef.current?.getBoundingClientRect() ?? null) : null; const triggerProps = { diff --git a/apps/fabro-web/app/hooks/use-debounced-value.ts b/apps/fabro-web/app/hooks/use-debounced-value.ts new file mode 100644 index 000000000..7adc04f82 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-debounced-value.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from "react"; + +/** + * Returns a debounced copy of `value` that only updates after `delayMs` + * milliseconds of stability. Synchronizes React state with a `setTimeout` + * timer; the timer is cancelled and reset whenever `value` or `delayMs` + * changes. + */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + return debounced; +} diff --git a/apps/fabro-web/app/hooks/use-document-title.ts b/apps/fabro-web/app/hooks/use-document-title.ts new file mode 100644 index 000000000..28cc3282d --- /dev/null +++ b/apps/fabro-web/app/hooks/use-document-title.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +/** + * Sets `document.title` to `title` and restores the previous title on unmount. + * Synchronizes React with the browser's `document.title` global. + */ +export function useDocumentTitle(title: string): void { + useEffect(() => { + const previous = document.title; + document.title = title; + return () => { + document.title = previous; + }; + }, [title]); +} diff --git a/apps/fabro-web/app/hooks/use-dot-language-ready.ts b/apps/fabro-web/app/hooks/use-dot-language-ready.ts new file mode 100644 index 000000000..cba789e6d --- /dev/null +++ b/apps/fabro-web/app/hooks/use-dot-language-ready.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { registerDotLanguage } from "../data/register-dot-language"; +import { useMountEffect } from "./use-mount-effect"; + +/** + * Triggers dot language registration with the Pierre syntax highlighter on + * mount and returns `true` once the async registration resolves. Components + * that render dot-syntax files should wait for this before rendering the + * highlighted view to avoid a flash of unstyled content. + * + * Registration is idempotent; duplicate calls from Strict Mode remount are + * harmless because `attachResolvedLanguages` only registers once per + * highlighter instance. + */ +export function useDotLanguageReady(): boolean { + const [ready, setReady] = useState(false); + + useMountEffect(() => { + let cancelled = false; + registerDotLanguage().then(() => { + if (!cancelled) setReady(true); + }); + return () => { + cancelled = true; + }; + }); + + return ready; +} diff --git a/apps/fabro-web/app/hooks/use-interval.ts b/apps/fabro-web/app/hooks/use-interval.ts new file mode 100644 index 000000000..42d1a5965 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-interval.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; + +/** + * Calls `callback` every `delayMs` milliseconds while `active` is true + * (default: always active). The interval is cleared when the component + * unmounts or when `active` or `delayMs` changes. + * + * The callback ref is updated on every render so the interval always sees + * the latest version without restarting. Synchronizes React with + * `setInterval`. + */ +export function useInterval( + callback: () => void, + delayMs: number, + active = true, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!active) return; + const id = setInterval(() => callbackRef.current(), delayMs); + return () => clearInterval(id); + }, [delayMs, active]); +} diff --git a/apps/fabro-web/app/hooks/use-media-query.ts b/apps/fabro-web/app/hooks/use-media-query.ts new file mode 100644 index 000000000..bfd55dbc6 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-media-query.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; + +const noop = () => () => {}; + +/** + * Returns `true` while the browser matches the given CSS media query string. + * Uses `useSyncExternalStore` to stay in sync with `MediaQueryList` changes + * without an effect. Falls back to `false` in SSR and test environments + * without a `window` global. + */ +export function useMediaQuery(query: string): boolean { + return useSyncExternalStore( + typeof window === "undefined" + ? noop + : (onStoreChange) => { + const mql = window.matchMedia(query); + mql.addEventListener("change", onStoreChange); + return () => mql.removeEventListener("change", onStoreChange); + }, + () => + typeof window === "undefined" ? false : window.matchMedia(query).matches, + () => false, + ); +} diff --git a/apps/fabro-web/app/hooks/use-mount-effect.ts b/apps/fabro-web/app/hooks/use-mount-effect.ts new file mode 100644 index 000000000..3b6f95523 --- /dev/null +++ b/apps/fabro-web/app/hooks/use-mount-effect.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; + +/** + * Runs `setup` once on mount. The function may return a cleanup that runs on + * unmount. Use this only when the code attaches to, creates, or subscribes to + * an external resource and the cleanup disposes it. + * + * Do not use `useMountEffect` as a way to avoid dependency arrays when the + * effect actually depends on changing React values — write a purpose-named hook + * with those values in its API instead. + */ +// eslint-disable-next-line react-hooks/exhaustive-deps +export function useMountEffect(setup: () => void | (() => void)): void { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(setup, []); +} diff --git a/apps/fabro-web/app/hooks/use-resize-observer.ts b/apps/fabro-web/app/hooks/use-resize-observer.ts new file mode 100644 index 000000000..d3113f77c --- /dev/null +++ b/apps/fabro-web/app/hooks/use-resize-observer.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, type RefObject } from "react"; + +/** + * Attaches a `ResizeObserver` to the element referenced by `ref` and calls + * `callback` with each `ResizeObserverEntry`. Disconnects on unmount or when + * the observed element changes. + * + * The callback ref is updated on every render so the latest version fires + * without restarting the observer. Synchronizes React with the browser + * `ResizeObserver` API. + */ +export function useResizeObserver( + ref: RefObject, + callback: (entry: ResizeObserverEntry) => void, +): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) callbackRef.current(entry); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [ref]); +} diff --git a/apps/fabro-web/app/hooks/use-window-event.ts b/apps/fabro-web/app/hooks/use-window-event.ts new file mode 100644 index 000000000..3dfa27b0d --- /dev/null +++ b/apps/fabro-web/app/hooks/use-window-event.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +/** + * Adds `handler` as a `window` event listener for `type` and removes it on + * unmount. The subscription restarts when `type` or `active` changes. + * + * The handler ref is updated on every render so the latest version fires + * without restarting the listener. Synchronizes React with `window.addEventListener`. + */ +export function useWindowEvent( + type: K, + handler: (event: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + active = true, +): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (!active || typeof window === "undefined") return; + const listener = (event: WindowEventMap[K]) => handlerRef.current(event); + window.addEventListener(type, listener, options); + return () => window.removeEventListener(type, listener, options); + // options intentionally omitted: changing options identity should not + // restart the listener. Pass a stable object if needed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, active]); +} diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts index 72120e43a..e22a7b809 100644 --- a/apps/fabro-web/app/lib/live-events.ts +++ b/apps/fabro-web/app/lib/live-events.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import type { Key } from "swr"; import { @@ -63,3 +64,21 @@ export function subscribeToLiveEvents( }), }); } + + +/** + * Subscribes to the live system event stream for the lifetime of the calling + * component. Calls `onEvent` for every incoming payload. Unsubscribes on + * unmount. Synchronizes React with the cross-tab SSE coordinator. + * + * The subscription is created once at mount; `onEvent` is kept current via a + * ref so the latest closure always fires without restarting the stream. + */ +export function useLiveEvents( + onEvent: (payload: LiveEventPayload) => void, +): void { + const onEventRef = useRef(onEvent); + onEventRef.current = onEvent; + + useEffect(() => subscribeToLiveEvents((payload) => onEventRef.current(payload)), []); +} diff --git a/apps/fabro-web/app/routes/automation-definition.tsx b/apps/fabro-web/app/routes/automation-definition.tsx index e587ed354..6c3156f40 100644 --- a/apps/fabro-web/app/routes/automation-definition.tsx +++ b/apps/fabro-web/app/routes/automation-definition.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from "react"; import { useOutletContext, useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; -import { registerDotLanguage } from "../data/register-dot-language"; +import { useDotLanguageReady } from "../hooks/use-dot-language-ready"; import { workflowData, type WorkflowEntry } from "./automation-detail"; import { CollapsibleFile } from "../components/collapsible-file"; @@ -9,17 +8,7 @@ export default function AutomationDefinition() { const { name } = useParams(); const context = useOutletContext<{ workflow?: WorkflowEntry } | null>(); const workflow = context?.workflow ?? workflowData[name ?? ""]; - const [dotReady, setDotReady] = useState(false); - - useEffect(() => { - let cancelled = false; - registerDotLanguage().then(() => { - if (!cancelled) setDotReady(true); - }); - return () => { - cancelled = true; - }; - }, []); + const dotReady = useDotLanguageReady(); if (workflow == null) { return

No settings found.

; diff --git a/apps/fabro-web/app/routes/automation-diagram.tsx b/apps/fabro-web/app/routes/automation-diagram.tsx index 55058d776..6c2aeaf9e 100644 --- a/apps/fabro-web/app/routes/automation-diagram.tsx +++ b/apps/fabro-web/app/routes/automation-diagram.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid"; import { graphTheme } from "../lib/graph-theme"; @@ -67,17 +67,21 @@ function stripGraphTitle(svg: SVGSVGElement) { const ZOOM_STEPS = [25, 50, 75, 100, 150, 200]; const DEFAULT_ZOOM_INDEX = 2; // 75% -export default function AutomationDiagram() { - const containerRef = useRef(null); - const innerRef = useRef(null); - const svgRef = useRef(null); - const [error, setError] = useState(null); - const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); - const [direction, setDirection] = useState("LR"); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); - const zoom = ZOOM_STEPS[zoomIndex]; - +/** + * Lazily loads @viz-js/viz, renders the DOT source for the given direction + * into an SVGElement, and places it in innerRef's DOM node. Cancels the + * async render when direction changes or the component unmounts. + * + * External systems: dynamic ESM import of @viz-js/viz, imperative DOM insertion. + * Cleanup: sets cancelled flag so in-flight renders are discarded. + */ +function useVizDiagram( + direction: Direction, + innerRef: RefObject, + svgRef: RefObject, + setError: (msg: string | null) => void, + setPan: (pan: { x: number; y: number }) => void, +): void { useEffect(() => { let cancelled = false; @@ -102,7 +106,23 @@ export default function AutomationDiagram() { setPan({ x: 0, y: 0 }); render(); return () => { cancelled = true; }; - }, [direction]); + // setError and setPan are stable React state setters; svgRef/innerRef are + // stable refs. Only direction triggers a new render. + }, [direction, innerRef, svgRef]); +} + +export default function AutomationDiagram() { + const containerRef = useRef(null); + const innerRef = useRef(null); + const svgRef = useRef(null); + const [error, setError] = useState(null); + const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX); + const [direction, setDirection] = useState("LR"); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); + const zoom = ZOOM_STEPS[zoomIndex]; + + useVizDiagram(direction, innerRef, svgRef, setError, setPan); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; diff --git a/apps/fabro-web/app/routes/chats-detail.tsx b/apps/fabro-web/app/routes/chats-detail.tsx index def71da9c..13d9b1400 100644 --- a/apps/fabro-web/app/routes/chats-detail.tsx +++ b/apps/fabro-web/app/routes/chats-detail.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; import { useNavigate, useParams } from "react-router"; import { AssistantRuntimeProvider, @@ -53,10 +54,9 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { // Keep latest `chat` accessible to the stable adapter closure below without // recreating the adapter (and the assistant-ui runtime) on every store dispatch. + // Updating during render is safe here because chatRef is not used to render UI. const chatRef = useRef(chat); - useEffect(() => { - chatRef.current = chat; - }); + chatRef.current = chat; const initialMessages = useMemo( () => toThreadMessages(chat.seedMessages), @@ -76,17 +76,14 @@ function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { // Autorespond: chats arriving here from /chats/new carry the user's first // message in seedMessages with pendingResponse=true. Trigger one startRun - // once per mount; the ref dedupes within a StrictMode mount cycle (state - // updates from consumePendingResponse aren't visible to the re-fired effect - // closure), and the store flag dedupes across mounts (e.g. navigating away - // and back to the same chat). - const didStartRef = useRef(false); - useEffect(() => { - if (!chat.pendingResponse || didStartRef.current) return; - didStartRef.current = true; + // once per mount. ChatRuntime is keyed by chatId so it mounts fresh for each + // chat; pendingResponse is set before mount and consumed here. The store flag + // in consumePendingResponse dedupes across mounts (e.g. navigating away and back). + useMountEffect(() => { + if (!chat.pendingResponse) return; consumePendingResponse(chatId); runtime.thread.startRun({ parentId: null }); - }, [chat.pendingResponse, chatId, consumePendingResponse, runtime]); + }); return ( diff --git a/apps/fabro-web/app/routes/insights-editor.tsx b/apps/fabro-web/app/routes/insights-editor.tsx index 97a19b2df..db71f8cdc 100644 --- a/apps/fabro-web/app/routes/insights-editor.tsx +++ b/apps/fabro-web/app/routes/insights-editor.tsx @@ -1,4 +1,6 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useCallback } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; +import { useResizeObserver } from "../hooks/use-resize-observer"; import { useLocation } from "react-router"; import { Dialog, @@ -113,20 +115,9 @@ function BarChart({ result }: { result: QueryResult }) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - setContainerWidth(entry.contentRect.width); - } - }); - // react-doctor-disable-next-line react-doctor/no-initialize-state -- ResizeObserver is the first reliable source for this rendered container's width. - observer.observe(el); - return () => observer.disconnect(); - }, []); + useResizeObserver(containerRef, (entry) => { + setContainerWidth(entry.contentRect.width); + }); const labelCol = result.columns[0]; const valueCols = result.columns.slice(1).filter((col) => { @@ -411,7 +402,8 @@ export default function InsightsEditor() { }, delay); }, [sql]); - useEffect(() => { + // Cancel any pending query run when the editor component unmounts. + useMountEffect(() => { const runRequestIds = runRequestIdRef; const runTimeouts = runTimeoutRef; return () => { @@ -421,7 +413,7 @@ export default function InsightsEditor() { runTimeouts.current = null; } }; - }, []); + }); return (
diff --git a/apps/fabro-web/app/routes/redirect-home.tsx b/apps/fabro-web/app/routes/redirect-home.tsx index a38e48044..2ab6695eb 100644 --- a/apps/fabro-web/app/routes/redirect-home.tsx +++ b/apps/fabro-web/app/routes/redirect-home.tsx @@ -1,22 +1,17 @@ -import { useEffect } from "react"; -import { useNavigate } from "react-router"; +import { Navigate } from "react-router"; import { ApiError } from "../lib/api-client"; import { useAuthMe } from "../lib/queries"; export default function RedirectHome() { - const navigate = useNavigate(); const { data, error } = useAuthMe(); - useEffect(() => { - if (data) { - navigate("/runs", { replace: true }); - return; - } + if (data) { + return ; + } - if (error instanceof ApiError && error.status === 401) { - navigate("/login", { replace: true }); - } - }, [data, error, navigate]); + if (error instanceof ApiError && error.status === 401) { + return ; + } return null; } diff --git a/apps/fabro-web/app/routes/run-artifacts.tsx b/apps/fabro-web/app/routes/run-artifacts.tsx index 40d4cf27f..16787b4fe 100644 --- a/apps/fabro-web/app/routes/run-artifacts.tsx +++ b/apps/fabro-web/app/routes/run-artifacts.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; import { useParams } from "react-router"; import { ArrowDownTrayIcon, PaperClipIcon } from "@heroicons/react/24/outline"; import type { RunArtifactEntry } from "@qltysh/fabro-api-client"; @@ -180,7 +181,10 @@ function StageGroupCard({ runId, group }: { runId: string; group: StageGroup }) function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry }) { const [href, setHref] = useState("#"); - useEffect(() => { + // Each ArtifactRow is keyed by the entry's identity so it mounts once per + // unique entry. The download URL is derived from immutable entry props plus + // the stable runId; computing it once on mount is correct. + useMountEffect(() => { let active = true; void stageArtifactDownloadUrl( runId, @@ -193,7 +197,7 @@ function ArtifactRow({ runId, entry }: { runId: string; entry: RunArtifactEntry return () => { active = false; }; - }, [entry.relative_path, entry.retry, entry.stage_id, runId]); + }); return (
  • diff --git a/apps/fabro-web/app/routes/run-children.tsx b/apps/fabro-web/app/routes/run-children.tsx index f4f5ab826..8463daf09 100644 --- a/apps/fabro-web/app/routes/run-children.tsx +++ b/apps/fabro-web/app/routes/run-children.tsx @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useInterval } from "../hooks/use-interval"; +import { useMountEffect } from "../hooks/use-mount-effect"; import { useParams, useSearchParams } from "react-router"; import { ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import type { ListRunsSortEnum } from "@qltysh/fabro-api-client"; @@ -86,13 +88,14 @@ export default function RunChildren() { [updatePreferences], ); - const hydratedFromStorage = useRef(false); - useEffect(() => { - if (hydratedFromStorage.current) return; - hydratedFromStorage.current = true; - if (searchParams === urlSearchParams) return; - setSearchParams(searchParams, { replace: true }); - }, [searchParams, urlSearchParams, setSearchParams]); + // Apply any URL defaults that were resolved from localStorage on mount so + // queries fire with the correct params. Runs only once; mount-time values + // are stable for this initialization purpose. + useMountEffect(() => { + if (searchParams !== urlSearchParams) { + setSearchParams(searchParams, { replace: true }); + } + }); const childRunsQuery = useRunsPage( { @@ -106,20 +109,19 @@ export default function RunChildren() { id != null, ); + // Track when data was last fetched so the relative timestamp stays fresh. + // Updated at render time when data identity changes so the "Updated just now" + // label appears on the same render as the new data (SWR already re-renders + // this component when childRunsQuery.data changes). const lastFetchedAtRef = useRef(null); + const prevDataRef = useRef(childRunsQuery.data); + if (childRunsQuery.data && childRunsQuery.data !== prevDataRef.current) { + prevDataRef.current = childRunsQuery.data; + lastFetchedAtRef.current = Date.now(); + } + const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - if (childRunsQuery.data) { - lastFetchedAtRef.current = Date.now(); - setNow(Date.now()); - } - }, [childRunsQuery.data]); - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 15_000); - return () => window.clearInterval(interval); - }, []); + useInterval(() => setNow(Date.now()), 15_000); const handleRefresh = useCallback(() => { void childRunsQuery.mutate(); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 3f3b75390..dbb92a196 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -52,8 +52,8 @@ import { } from "./run-detail/lifecycle-toasts"; import { buildRunDetailRun, - useTickingNow, } from "./run-detail/model"; +import { useTickingNow } from "../lib/time"; import { buildRunDetailTabs, childRouteLayoutFlags, @@ -104,7 +104,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { childrenCount, }); const steerBarRef = useRef(null); - const now = useTickingNow(30_000); + const now = useTickingNow(true, 30_000); const { fullHeight, hideSteerBar } = childRouteLayoutFlags(matches); useRunEvents(params.id); diff --git a/apps/fabro-web/app/routes/run-detail/docked-controls.tsx b/apps/fabro-web/app/routes/run-detail/docked-controls.tsx index b9c2eec32..954e66d17 100644 --- a/apps/fabro-web/app/routes/run-detail/docked-controls.tsx +++ b/apps/fabro-web/app/routes/run-detail/docked-controls.tsx @@ -4,6 +4,22 @@ import { type ReactNode, type RefObject, } from "react"; + +/** + * Registers the Ask Fabro sidebar's current pixel width with the shared layout + * context so sibling panels can respond to it. Resets to zero on unmount so + * the context does not retain a stale width after this component is removed. + * + * External system: `useAskFabroLayout` shared layout context. + * Cleanup: resets width to 0 on unmount. + */ +function useAskFabroSidebarWidth(sidebarWidth: number): void { + const { setSidebarWidth } = useAskFabroLayout(); + useEffect(() => { + setSidebarWidth(sidebarWidth); + return () => setSidebarWidth(0); + }, [sidebarWidth, setSidebarWidth]); +} import { SparklesIcon } from "@heroicons/react/20/solid"; import AskFabroSidebar, { @@ -52,12 +68,8 @@ export function RunDetailAskFabroShell({ const [askOpen, setAskOpen] = useState(false); const [askWidth, setAskWidth] = useState(SIDEBAR_WIDTH); const sidebarWidth = askAvailable && askOpen ? askWidth : 0; - const { setSidebarWidth, isResizing } = useAskFabroLayout(); - - useEffect(() => { - setSidebarWidth(sidebarWidth); - return () => setSidebarWidth(0); - }, [sidebarWidth, setSidebarWidth]); + const { isResizing } = useAskFabroLayout(); + useAskFabroSidebarWidth(sidebarWidth); return ( <> diff --git a/apps/fabro-web/app/routes/run-detail/model.ts b/apps/fabro-web/app/routes/run-detail/model.ts index 87ff0a97d..758009ea7 100644 --- a/apps/fabro-web/app/routes/run-detail/model.ts +++ b/apps/fabro-web/app/routes/run-detail/model.ts @@ -1,5 +1,3 @@ -import { useEffect, useState } from "react"; - import { isRunStatus, mapRunToRunItem, @@ -11,15 +9,6 @@ export function classNames(...classes: Array) return classes.filter(Boolean).join(" "); } -export function useTickingNow(intervalMs: number): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), intervalMs); - return () => clearInterval(id); - }, [intervalMs]); - return now; -} - export type RunDetailRun = ReturnType & { statusLabel: string; statusDot: string; diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index 6e0b5e391..ee3aad983 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -10,6 +10,10 @@ import { type ReactElement, type RefObject, } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; +import { useMediaQuery } from "../hooks/use-media-query"; +import { useInterval } from "../hooks/use-interval"; +import { useWindowEvent } from "../hooks/use-window-event"; import { useLocation, useNavigate, useParams } from "react-router"; import { MultiFileDiff, @@ -78,18 +82,7 @@ export function normalizeRunFileScope(value: string | null): RunFileScope { } function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState(() => { - if (typeof window === "undefined") return false; - return window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`).matches; - }); - useEffect(() => { - if (typeof window === "undefined") return; - const mql = window.matchMedia(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`); - const apply = () => setNarrow(mql.matches); - mql.addEventListener("change", apply); - return () => mql.removeEventListener("change", apply); - }, []); - return narrow; + return useMediaQuery(`(max-width: ${MD_BREAKPOINT_PX - 1}px)`); } function useFreshness( @@ -102,11 +95,7 @@ function useFreshness( const hasLabel = !!meta && (!!meta.to_sha_committed_at || lastFetchedAt !== null); const [, setTick] = useState(0); - useEffect(() => { - if (!hasLabel) return undefined; - const id = setInterval(() => setTick((t) => t + 1), 10_000); - return () => clearInterval(id); - }, [hasLabel]); + useInterval(() => setTick((t) => t + 1), 10_000, hasLabel); if (!meta) return null; const now = Date.now(); @@ -338,6 +327,111 @@ const RunFileRow = memo(function RunFileRow({ ); }); +// --------------------------------------------------------------------------- +// Route-scoped integration hooks +// --------------------------------------------------------------------------- + +/** + * Manages the "last good data" fallback for failed SWR revalidations and shows + * a toast when files transition from present to empty. Wraps the effect so the + * route component body stays free of direct useEffect calls. + * + * External systems: toast notification service (push) and the SWR cache. + * Cleanup: none required (effect only reads + writes refs and calls push). + */ +function useRunFileTransition( + filesQuery: ReturnType, + push: ReturnType["push"], +): { + data: PaginatedRunFileList | null; + lastFetchedAt: number | null; + prevToSha: string | null; + revalidationError: string | null; + initialError: ApiError | null; +} { + const lastGoodDataRef = useRef(null); + const lastFetchedAtRef = useRef(null); + + // prevToSha is captured before the effect so the render that triggered the + // new fetch still sees the prior sha (enabling the refresh-disabled check). + const prevToSha = lastGoodDataRef.current?.meta?.to_sha ?? null; + + useEffect(() => { + if (!filesQuery.data) return; + const message = emptyTransitionToastMessage( + lastGoodDataRef.current?.data.length ?? null, + filesQuery.data.data.length, + ); + if (message) push({ message }); + lastGoodDataRef.current = filesQuery.data; + lastFetchedAtRef.current = Date.now(); + }, [push, filesQuery.data]); + + const data = filesQuery.data ?? lastGoodDataRef.current; + const apiError = filesQuery.error instanceof ApiError ? filesQuery.error : null; + const revalidationError = + apiError && lastGoodDataRef.current + ? `Couldn't refresh (${apiError.status}).` + : null; + const initialError = apiError && !lastGoodDataRef.current ? apiError : null; + + return { data, lastFetchedAt: lastFetchedAtRef.current, prevToSha, revalidationError, initialError }; +} + +/** + * Returns keyboard focus to a button after a boolean `active` flag transitions + * from true → false (e.g. after an async refresh visibly completes). + * + * External system: browser focus API. + * Cleanup: none required (no resource is acquired). + */ +function useFocusAfterActive( + active: boolean, + ref: RefObject, +): void { + const prevRef = useRef(false); + useEffect(() => { + if (prevRef.current && !active) { + ref.current?.focus({ preventScroll: true }); + } + prevRef.current = active; + }, [active, ref]); +} + +/** + * After URL hash and file data have both settled, scrolls to and focuses the + * deep-link target row, or shows a "not found" toast when the file is absent. + * + * External systems: browser DOM scroll/focus APIs, toast notification service. + * Cleanup: none required (no resource is acquired). + */ +function useDeepLinkFocus( + hashFile: string | null, + data: PaginatedRunFileList | null, + push: ReturnType["push"], + lastDeepLinkToastRef: RefObject, +): void { + useEffect(() => { + const toast = resolveDeepLinkToast(hashFile, data); + if (toast) { + if (lastDeepLinkToastRef.current !== toast.key) { + push({ message: toast.message, autoDismissMs: 5000 }); + lastDeepLinkToastRef.current = toast.key; + } + return; + } + lastDeepLinkToastRef.current = null; + if (!hashFile || !data) return; + const el = document.getElementById(fileRowId(hashFile)); + if (el) { + el.scrollIntoView({ block: "start", behavior: "smooth" }); + el.focus({ preventScroll: true }); + } + }, [data, hashFile, push, lastDeepLinkToastRef]); +} + +// --------------------------------------------------------------------------- + function RunFilesLoaded({ containerRef, toolbar, @@ -475,41 +569,18 @@ export default function RunFiles() { // Preserve the last successful payload so a failed revalidation can keep // rendering the previous files while surfacing an inline banner. - const lastGoodDataRef = useRef(null); - const lastFetchedAtRef = useRef(null); - - useEffect(() => { - if (!filesQuery.data) return; - const message = emptyTransitionToastMessage( - lastGoodDataRef.current?.data.length ?? null, - filesQuery.data.data.length, - ); - if (message) { - push({ message }); - } - lastGoodDataRef.current = filesQuery.data; - lastFetchedAtRef.current = Date.now(); - }, [push, filesQuery.data]); - - const data: PaginatedRunFileList | null = - filesQuery.data ?? lastGoodDataRef.current; + const { + data, + lastFetchedAt, + prevToSha, + revalidationError, + initialError, + } = useRunFileTransition(filesQuery, push); const isInitialLoading = (waitingForCommitSelection || filesQuery.isLoading) && !data; const isRevalidating = filesQuery.isValidating; - // Revalidation error is whatever the most recent loader call returned; - // the inline banner renders when we still have prior data to show. When - // there's no prior data AND this is the initial load, we render a - // full-panel error state instead (the Toolbar would have nothing to act - // on with no data). - const apiError = filesQuery.error instanceof ApiError ? filesQuery.error : null; - const revalidationError = - apiError && lastGoodDataRef.current - ? `Couldn't refresh (${apiError.status}).` - : null; - const initialError = apiError && !lastGoodDataRef.current ? apiError : null; - - const freshness = useFreshness(data?.meta ?? null, lastFetchedAtRef.current); + const freshness = useFreshness(data?.meta ?? null, lastFetchedAt); // Persisted desktop preference + md-breakpoint forced unified. const [persistedStyle, setPersistedStyle] = useState( @@ -565,20 +636,14 @@ export default function RunFiles() { }, [routeLocation.hash, routeLocation.pathname, routeLocation.search, navigate], ); - useEffect(() => clearMinRefreshTimer, [clearMinRefreshTimer]); + // Cancel the minimum-refresh timer when the view unmounts. + useMountEffect(() => clearMinRefreshTimer); // react-doctor-disable-next-line react-doctor/no-event-handler -- The refresh spinner is driven by both SWR revalidation and the click-owned minimum timer. const showRefreshing = isRevalidating || minRefreshActive; // Return focus to the Refresh button after a refresh visibly completes so // keyboard-first users stay oriented. - const refreshingPrev = useRef(false); - useEffect(() => { - // react-doctor-disable-next-line react-doctor/no-event-handler -- Returning focus after async refresh completion is an accessibility sync effect. - if (refreshingPrev.current && !showRefreshing) { - refreshButtonRef.current?.focus({ preventScroll: true }); - } - refreshingPrev.current = showRefreshing; - }, [showRefreshing]); + useFocusAfterActive(showRefreshing, refreshButtonRef); const fileCount = data?.data.length ?? 0; useFileKeyboardNav(containerRef, fileCount); @@ -592,33 +657,13 @@ export default function RunFiles() { if (typeof window === "undefined") return null; return decodeDeepLinkFile(window.location.hash); }); - useEffect(() => { - if (typeof window === "undefined") return; - const onHashChange = () => - setHashFile(decodeDeepLinkFile(window.location.hash)); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); + useWindowEvent("hashchange", () => + setHashFile(decodeDeepLinkFile(window.location.hash)), + ); - // react-doctor-disable-next-line react-doctor/no-event-handler -- Deep-link focus has to run after URL hash and file data have both rendered matching DOM rows. - useEffect(() => { - // react-doctor-disable-next-line react-doctor/no-event-handler -- Toasting missing deep links also depends on resolved file data. - const toast = resolveDeepLinkToast(hashFile, data); - if (toast) { - if (lastDeepLinkToastRef.current !== toast.key) { - push({ message: toast.message, autoDismissMs: 5000 }); - lastDeepLinkToastRef.current = toast.key; - } - return; - } - lastDeepLinkToastRef.current = null; - if (!hashFile || !data) return; - const el = document.getElementById(fileRowId(hashFile)); - if (el) { - el.scrollIntoView({ block: "start", behavior: "smooth" }); - el.focus({ preventScroll: true }); - } - }, [data, hashFile, push]); + // After URL hash and file data have both settled, scroll to + focus the row + // (or show a "not found" toast when the file is absent). + useDeepLinkFocus(hashFile, data, push, lastDeepLinkToastRef); const handleFileSelect = useCallback((path: string) => { if (typeof window === "undefined") return; @@ -666,9 +711,6 @@ export default function RunFiles() { // Refresh is disabled when the server reports the same `to_sha` it // reported on the previous successful fetch — no new checkpoint yet. - // `lastGoodDataRef.current` is updated in a useEffect, so during render - // it still holds the previous render's data (or null on first load). - const prevToSha = lastGoodDataRef.current?.meta?.to_sha ?? null; const refreshDisabled = !!meta.to_sha && prevToSha !== null && prevToSha === meta.to_sha; diff --git a/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx b/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx index c07317b23..afa45efcb 100644 --- a/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx +++ b/apps/fabro-web/app/routes/run-files/file-tree-sidebar.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, type CSSProperties, + type RefObject, } from "react"; import { FileTree, @@ -67,6 +68,71 @@ function syncSelection( if (item && !item.isSelected()) item.select(); } +/** + * Keeps the Pierre FileTree imperative model aligned with React props and + * with the model's own selection state. + * + * Two separate concerns are managed here: + * + * 1. Path / git-status sync (paths/gitStatus/model deps): when the file list + * changes, resetPaths and setGitStatus are called. A didSyncModelRef guard + * skips the initial run because useFileTree already initialises the model + * with the first render's values. + * + * 2. Selection sync (selection/selectedPath/changedPaths deps): keeps the + * tree's highlighted row consistent with both the URL-controlled + * `selectedPath` prop and any pending selection written by the + * onSelectionChange callback. + * + * External systems: @pierre/trees imperative FileTreeModel API. + * Cleanup: none required (no resource is acquired). + */ +function useFileTreeModelSync( + model: FileTreeModel, + paths: string[], + gitStatus: GitStatusEntry[], + selectedPath: string | null, + changedPaths: ReadonlySet, + pendingSelectedPathRef: RefObject, + selectedPathRef: RefObject, + changedPathsRef: RefObject>, +): void { + const didSyncModelRef = useRef(false); + useEffect(() => { + if (!didSyncModelRef.current) { + didSyncModelRef.current = true; + return; + } + model.resetPaths(paths); + model.setGitStatus(gitStatus); + pendingSelectedPathRef.current = null; + const currentSelectedPath = selectedPathRef.current; + syncSelection( + model, + model.getSelectedPaths(), + currentSelectedPath && changedPathsRef.current.has(currentSelectedPath) + ? currentSelectedPath + : null, + ); + }, [gitStatus, model, paths, pendingSelectedPathRef, selectedPathRef, changedPathsRef]); + + const selection = useFileTreeSelection(model); + useEffect(() => { + const pendingSelectedPath = pendingSelectedPathRef.current; + // Keeps Pierre's imperative tree model aligned after the tree emits a + // selection change. + if (pendingSelectedPath === selectedPath) { + pendingSelectedPathRef.current = null; + } + const nextSelectedPath = pendingSelectedPath ?? selectedPath; + syncSelection( + model, + selection, + nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null, + ); + }, [changedPaths, model, pendingSelectedPathRef, selectedPath, selection]); +} + interface FileTreeSidebarProps { files: readonly FileDiff[]; selectedPath: string | null; @@ -117,39 +183,16 @@ export function FileTreeSidebar({ }, }); - const didSyncModelRef = useRef(false); - useEffect(() => { - if (!didSyncModelRef.current) { - didSyncModelRef.current = true; - return; - } - model.resetPaths(paths); - model.setGitStatus(gitStatus); - pendingSelectedPathRef.current = null; - const currentSelectedPath = selectedPathRef.current; - syncSelection( - model, - model.getSelectedPaths(), - currentSelectedPath && changedPathsRef.current.has(currentSelectedPath) - ? currentSelectedPath - : null, - ); - }, [gitStatus, model, paths]); - - const selection = useFileTreeSelection(model); - useEffect(() => { - const pendingSelectedPath = pendingSelectedPathRef.current; - // react-doctor-disable-next-line react-doctor/no-event-handler -- This keeps Pierre's imperative tree model aligned after the tree emits a selection change. - if (pendingSelectedPath === selectedPath) { - pendingSelectedPathRef.current = null; - } - const nextSelectedPath = pendingSelectedPath ?? selectedPath; - syncSelection( - model, - selection, - nextSelectedPath && changedPaths.has(nextSelectedPath) ? nextSelectedPath : null, - ); - }, [changedPaths, model, selectedPath, selection]); + useFileTreeModelSync( + model, + paths, + gitStatus, + selectedPath, + changedPaths, + pendingSelectedPathRef, + selectedPathRef, + changedPathsRef, + ); const themeStyles = useMemo( () => ({ diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 131c11738..6a45f5416 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { createPortal } from "react-dom"; import { useNavigate, useParams } from "react-router"; import { graphTheme } from "../lib/graph-theme"; @@ -24,59 +24,25 @@ import { const HOVER_OPEN_DELAY_MS = 200; -interface NodeHover { - stage: Stage; - rect: DOMRect; -} - -export const handle = { wide: true }; - -type Direction = "LR" | "TB"; - -export default function RunOverview() { - const { id } = useParams(); - const [direction, setDirection] = useState("LR"); - const stagesQuery = useRunStages(id); - const graphQuery = useRunGraph(id, direction); - const runQuery = useRun(id); - const stages = useMemo( - () => mapRunStagesToSidebarStages(stagesQuery.data), - [stagesQuery.data], - ); - const graphSvg = graphQuery.data; - const graphErrorDescription = - graphQuery.error instanceof ApiError - ? graphQuery.error.message - : graphQuery.error - ? "The graph render request failed." - : undefined; - const apiStatus = runQuery.data?.lifecycle.status; - const terminalOutcome: "succeeded" | "failed" | "dead" | null = - apiStatus?.kind === "succeeded" || - apiStatus?.kind === "failed" || - apiStatus?.kind === "dead" - ? apiStatus.kind - : null; - const containerRef = useRef(null); - const innerRef = useRef(null); - const svgRef = useRef(null); - const navigate = useNavigate(); - const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX); - const [pan, setPan] = useState({ x: 0, y: 0 }); - const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); - const zoom = GRAPH_ZOOM_STEPS[zoomIndex]; - const [hoveredNode, setHoveredNode] = useState(null); - - // Per-stage lookup keyed by latest visit's `stageId`, used when the SVG's - // imperative hover handlers need to resolve a node to its sidebar Stage. - const stageById = useMemo(() => { - const map = new Map(); - for (const stage of stages) map.set(stage.id, stage); - return map; - }, [stages]); - - // Render SVG with stage annotations - // react-doctor-disable-next-line react-doctor/no-cascading-set-state -- This effect mutates local Set/Map instances and the Graphviz SVG DOM; it does not call React state setters. +/** + * Sets the SVG innerHTML from the Graphviz API response, colors nodes by their + * current run status, and attaches click/hover listeners to each SVG node group. + * + * External systems: raw SVG DOM (innerHTML mutation + createElement), browser + * event listeners, and a CSS animation via SVGAnimateElement. + * Cleanup: removes all attached listeners and clears the hover popover. + */ +function useGraphSvgAnnotations( + innerRef: RefObject, + svgRef: RefObject, + graphSvg: string | undefined, + stages: Stage[], + stageById: Map, + id: string | undefined, + navigate: (to: string) => void, + terminalOutcome: "succeeded" | "failed" | "dead" | null, + setHoveredNode: (node: NodeHover | null) => void, +): void { useEffect(() => { const inner = innerRef.current; if (!inner || !graphSvg) return; @@ -211,7 +177,76 @@ export default function RunOverview() { } setHoveredNode(null); }; + // setHoveredNode is a stable state setter; omitted from deps intentionally. + // navigate is stable from useNavigate. }, [stages, stageById, graphSvg, id, navigate, terminalOutcome]); +} + +interface NodeHover { + stage: Stage; + rect: DOMRect; +} + +export const handle = { wide: true }; + +type Direction = "LR" | "TB"; + +export default function RunOverview() { + const { id } = useParams(); + const [direction, setDirection] = useState("LR"); + const stagesQuery = useRunStages(id); + const graphQuery = useRunGraph(id, direction); + const runQuery = useRun(id); + const stages = useMemo( + () => mapRunStagesToSidebarStages(stagesQuery.data), + [stagesQuery.data], + ); + const graphSvg = graphQuery.data; + const graphErrorDescription = + graphQuery.error instanceof ApiError + ? graphQuery.error.message + : graphQuery.error + ? "The graph render request failed." + : undefined; + const apiStatus = runQuery.data?.lifecycle.status; + const terminalOutcome: "succeeded" | "failed" | "dead" | null = + apiStatus?.kind === "succeeded" || + apiStatus?.kind === "failed" || + apiStatus?.kind === "dead" + ? apiStatus.kind + : null; + const containerRef = useRef(null); + const innerRef = useRef(null); + const svgRef = useRef(null); + const navigate = useNavigate(); + const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); + const zoom = GRAPH_ZOOM_STEPS[zoomIndex]; + const [hoveredNode, setHoveredNode] = useState(null); + + // Per-stage lookup keyed by latest visit's `stageId`, used when the SVG's + // imperative hover handlers need to resolve a node to its sidebar Stage. + const stageById = useMemo(() => { + const map = new Map(); + for (const stage of stages) map.set(stage.id, stage); + return map; + }, [stages]); + + // Render SVG with stage annotations: sets innerHTML, colors nodes, and + // attaches click/hover listeners. Extracted to a named hook to keep this + // component body free of direct useEffect calls. + useGraphSvgAnnotations( + innerRef, + svgRef, + graphSvg, + stages, + stageById, + id, + navigate, + terminalOutcome, + setHoveredNode, + ); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; diff --git a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx index 0c8bb4a96..23404dbb3 100644 --- a/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/filesystem-panel.tsx @@ -1,6 +1,5 @@ import { useCallback, - useEffect, useMemo, useRef, useState, @@ -371,9 +370,11 @@ function DirectoryPane({ }, }); - useEffect(() => { - model.resetPaths(treeInputs.paths); - }, [model, treeInputs.paths]); + // Render-phase model sync: useFileTree only consumes `paths` at construction + // time, so keep the imperative model in sync on every render by calling + // resetPaths directly. This is safe because resetPaths only mutates the + // external widget model, not React state. + model.resetPaths(treeInputs.paths); const themeStyles = useMemo( () => ({ diff --git a/apps/fabro-web/app/routes/run-source.tsx b/apps/fabro-web/app/routes/run-source.tsx index 621e94de0..022a74687 100644 --- a/apps/fabro-web/app/routes/run-source.tsx +++ b/apps/fabro-web/app/routes/run-source.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; import { useRunGraphSource, useRunStages } from "../lib/queries"; import { LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { CollapsibleFile } from "../components/collapsible-file"; -import { registerDotLanguage } from "../data/register-dot-language"; +import { useDotLanguageReady } from "../hooks/use-dot-language-ready"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true }; @@ -18,17 +18,7 @@ export default function RunSource() { () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], ); - const [dotReady, setDotReady] = useState(false); - - useEffect(() => { - let cancelled = false; - registerDotLanguage().then(() => { - if (!cancelled) setDotReady(true); - }); - return () => { - cancelled = true; - }; - }, []); + const dotReady = useDotLanguageReady(); const source = sourceQuery.data; const loading = source === undefined && !sourceQuery.error; diff --git a/apps/fabro-web/app/routes/run-terminal.tsx b/apps/fabro-web/app/routes/run-terminal.tsx index 78c7f42cf..b6fb6d3cb 100644 --- a/apps/fabro-web/app/routes/run-terminal.tsx +++ b/apps/fabro-web/app/routes/run-terminal.tsx @@ -1,16 +1,9 @@ -import { useEffect } from "react"; - +import { useDocumentTitle } from "../hooks/use-document-title"; import TerminalView from "../components/terminal-view"; import { ToastProvider } from "../components/toast"; export default function RunTerminal({ params }: { params: { id: string } }) { - useEffect(() => { - const previous = document.title; - document.title = `Terminal · ${params.id} · Fabro`; - return () => { - document.title = previous; - }; - }, [params.id]); + useDocumentTitle(`Terminal · ${params.id} · Fabro`); return ( diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 28bc4a9d6..50f5c3a83 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { Link } from "react-router"; import { CheckIcon, ChevronDownIcon, CommandLineIcon } from "@heroicons/react/24/outline"; import { EllipsisVerticalIcon } from "@heroicons/react/20/solid"; @@ -781,13 +781,20 @@ export default function Runs() { ); allWorkflows.sort(); const [columns, setColumns] = useState(initialColumns); + + // Sync columns with incoming SWR data. Calling setColumns during render + // (the render-phase state update pattern) avoids an effect and the extra + // render round-trip. React re-renders this component immediately with the + // updated columns while preserving drag-state between fetches. + const prevInitialColumnsRef = useRef(initialColumns); + if (prevInitialColumnsRef.current !== initialColumns) { + prevInitialColumnsRef.current = initialColumns; + setColumns(initialColumns); + } + const lowerQuery = query.toLowerCase(); useBoardEvents(); - useEffect(() => { - setColumns(initialColumns); - }, [initialColumns]); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), diff --git a/apps/fabro-web/app/routes/runs/workspace-preferences.ts b/apps/fabro-web/app/routes/runs/workspace-preferences.ts index ee1e2aff5..04416440e 100644 --- a/apps/fabro-web/app/routes/runs/workspace-preferences.ts +++ b/apps/fabro-web/app/routes/runs/workspace-preferences.ts @@ -1,9 +1,8 @@ import { useCallback, - useEffect, useMemo, - useRef, } from "react"; +import { useMountEffect } from "../../hooks/use-mount-effect"; import { useSearchParams } from "react-router"; import type { BoardColumn, ListRunsSortEnum } from "@qltysh/fabro-api-client"; @@ -103,13 +102,14 @@ export function useRunsWorkspacePreferences() { [updatePreferences], ); - const hydratedFromStorage = useRef(false); - useEffect(() => { - if (hydratedFromStorage.current) return; - hydratedFromStorage.current = true; - if (searchParams === urlSearchParams) return; - setSearchParams(searchParams, { replace: true }); - }, [searchParams, urlSearchParams, setSearchParams]); + // Apply any URL defaults that were resolved from localStorage on mount so + // queries fire with the correct params. Runs only once; mount-time values + // are stable for this initialization purpose. + useMountEffect(() => { + if (searchParams !== urlSearchParams) { + setSearchParams(searchParams, { replace: true }); + } + }); return { query, diff --git a/apps/fabro-web/app/routes/settings-live-events.tsx b/apps/fabro-web/app/routes/settings-live-events.tsx index 4300ed6d8..bcfa7bff0 100644 --- a/apps/fabro-web/app/routes/settings-live-events.tsx +++ b/apps/fabro-web/app/routes/settings-live-events.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Link } from "react-router"; import { @@ -18,7 +18,7 @@ import { Tooltip } from "../components/ui"; import { eventDedupeKey } from "../lib/cross-tab-sse"; import { formatAbsoluteTs } from "../lib/format"; import { - subscribeToLiveEvents, + useLiveEvents, type LiveEventPayload, } from "../lib/live-events"; @@ -49,11 +49,9 @@ export default function SettingsLiveEvents() { const [selectedCategories, setSelectedCategories] = useState([]); const [search, setSearch] = useState(""); - useEffect(() => { - return subscribeToLiveEvents((payload) => { - setEvents((prev) => appendLiveEvent(prev, payload)); - }); - }, []); + useLiveEvents((payload) => { + setEvents((prev) => appendLiveEvent(prev, payload)); + }); const filtered = useMemo(() => { const useCategoryFilter = selectedCategories.length > 0; diff --git a/apps/fabro-web/app/routes/settings-models.tsx b/apps/fabro-web/app/routes/settings-models.tsx index 6a3867275..487a2d192 100644 --- a/apps/fabro-web/app/routes/settings-models.tsx +++ b/apps/fabro-web/app/routes/settings-models.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; import type { ReactNode } from "react"; import { Link } from "react-router"; import { @@ -610,11 +611,4 @@ function sortModels( return sorted; } -function useDebouncedValue(value: T, delayMs: number): T { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const id = setTimeout(() => setDebounced(value), delayMs); - return () => clearTimeout(id); - }, [value, delayMs]); - return debounced; -} + diff --git a/apps/fabro-web/app/routes/start.tsx b/apps/fabro-web/app/routes/start.tsx index 79769c6a9..5ec7761a4 100644 --- a/apps/fabro-web/app/routes/start.tsx +++ b/apps/fabro-web/app/routes/start.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef } from "react"; +import { useMountEffect } from "../hooks/use-mount-effect"; import { Listbox, ListboxButton, @@ -50,9 +51,9 @@ export default function Start() { const [openCategory, setOpenCategory] = useState(null); const textareaRef = useRef(null); - useEffect(() => { + useMountEffect(() => { textareaRef.current?.focus(); - }, []); + }); function autoResize() { const el = textareaRef.current;