mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
## Summary
Implements the React Effects Policy by creating the approved hook
surface in `hooks/effects.ts` and migrating a broad set of direct
`useEffect` calls across the codebase to either purpose-named hooks or
non-effect patterns.
### Plan Summary
- Add `hooks/effects.ts` exporting `useMountEffect`, `useInterval`,
`useTimeout`, `useDebouncedValue`, `useWindowEvent`, `useDocumentEvent`,
`useDocumentTitle`, `useMediaQuery`, `useLocationHash`, and
`useResizeObserver`
- Extract large imperative effects into purpose-named hooks:
`useTerminalSession`, `useFloatingTooltipMeasurements`,
`useAnnotatedRunGraphSvg`, `useInstallEffects`, and others
- Move install session fetch from a component effect into a SWR query
(`install-query.ts`)
- Replace `useEffect` + `useState` state-derivation patterns with
render-time computation or ref callbacks
- Replace `AskFabroLayoutProvider`/`useAskFabroLayout` context with a
prop callback
## What changed and why
**`hooks/effects.ts`** — the new approved primitive surface. All
internal `useEffect` calls here are intentional; the hooks expose the
*external system* they manage rather than leaking `useEffect` to
component code. `useMediaQuery` and `useLocationHash` use
`useSyncExternalStore` instead of effect + state.
**`useTerminalSession`** — the largest extraction. The 130-line
xterm/WebSocket/ResizeObserver setup block moves from
`terminal-view.tsx` into its own hook, which now owns the `terminalRef`,
`fitRef`, and `socketRef` that previously cluttered the component.
`TerminalConnectionError` and `ConnectionStatus` types are exported from
the hook.
**`useFloatingTooltipMeasurements`** — extracts the `useLayoutEffect` +
ResizeObserver + window resize listener out of `FloatingTooltip`. The
`FloatingTooltipSize` type moves with it so consumers don't need to
import from the component.
**`useInstallSessionQuery` + `useInstallEffects`** — the install session
fetch moves from a component effect to SWR (`install-query.ts`). The
three remaining install effects (token URL scrubbing, GitHub error URL
scrubbing, health-poll restart) move into
`hooks/use-install-effects.ts`. The root-redirect effect is replaced
with a render-time `<Navigate>` gate. The `SessionState` discriminant
now carries `token` so stale query results can be discarded without an
effect chain.
**`SelectionCheckbox`** — `useEffect` setting `input.indeterminate` is
replaced with a ref callback, which runs synchronously after the node is
attached and avoids a stale-frame flash.
**`event-debug.tsx`** — the manual `window.addEventListener("keydown",
...)` pattern is replaced with `useWindowEvent`, removing the
`react-doctor-disable` suppression comments.
**`run-waterfall.tsx`** — the local `useTickingNow` is deleted;
`RunWaterfall` now calls the shared `useTickingNow` from `lib/time` with
the new `active` parameter signature.
**`toast.test.tsx`** — `useEffect(() => onReady?.(api), ...)` in the
test helper is replaced with a direct call during render, which is valid
because `onReady` has no side effects that React cares about.
**`AskFabroSidebar`** — `setIsResizing` from the layout context is
replaced with an `onResizeActiveChange` prop, removing the
`useAskFabroLayout` call and the hidden context coupling from the
sidebar.
### Fabro Details
<details>
<summary>Ran 3 stages in 114m 5s for $95.71</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| work | 103m 3s | $80.42 | 0 |
| audit | 10m 19s | $15.29 | 0 |
| **Total** | **114m 5s** | **$95.71** | **0** |
</details>
<details>
<summary>Ran <code>Goal.fabro</code> (4 nodes and 5 edges)</summary>
```dot
digraph Goal {
graph [
goal="Complete the user-provided goal",
rankdir=LR,
max_node_visits=30
]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
work [
label="Work",
thread_id="goal",
fidelity="full",
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/continue.md"
]
audit [
label="Completion Audit",
thread_id="goal",
fidelity="full",
goal_gate=true,
retry_target="work",
output_schema="routing",
output_retries=2,
max_visits=12,
model="gpt-55",
reasoning_effort="xhigh",
prompt="@prompts/audit.md"
]
start -> work -> audit
audit -> exit [label="Done", condition="outcome=succeeded"]
audit -> work [label="Continue", condition="outcome=failed || preferred_label=Continue"]
audit -> work [label="No clear verdict"]
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
206 lines
6.2 KiB
TypeScript
206 lines
6.2 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
type EffectCallback,
|
|
type RefObject,
|
|
} from "react";
|
|
|
|
/**
|
|
* Synchronizes React with a resource that is created for the mounted lifetime
|
|
* only. The returned cleanup is run on unmount, including Strict Mode remounts.
|
|
*/
|
|
export function useMountEffect(setup: EffectCallback): void {
|
|
useEffect(setup, []);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with the browser timer queue. The interval is started
|
|
* while `active` is true and is always cleared before the hook resubscribes or
|
|
* unmounts.
|
|
*/
|
|
export function useInterval(
|
|
callback: () => void,
|
|
delayMs: number,
|
|
active = true,
|
|
): void {
|
|
const callbackRef = useRef(callback);
|
|
callbackRef.current = callback;
|
|
|
|
useEffect(() => {
|
|
if (!active) return undefined;
|
|
const id = setInterval(() => callbackRef.current(), delayMs);
|
|
return () => clearInterval(id);
|
|
}, [active, delayMs]);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with the browser timer queue. The timeout is scheduled
|
|
* while `active` is true and is always cleared before it can fire after
|
|
* unmount.
|
|
*/
|
|
export function useTimeout(
|
|
callback: () => void,
|
|
delayMs: number,
|
|
active = true,
|
|
): void {
|
|
const callbackRef = useRef(callback);
|
|
callbackRef.current = callback;
|
|
|
|
useEffect(() => {
|
|
if (!active) return undefined;
|
|
const id = setTimeout(() => callbackRef.current(), delayMs);
|
|
return () => clearTimeout(id);
|
|
}, [active, delayMs]);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes a value with the browser timer queue. Pending debounce timers are
|
|
* cleared when the value or delay changes and on unmount.
|
|
*/
|
|
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
|
const [debounced, setDebounced] = useState(value);
|
|
|
|
useEffect(() => {
|
|
const id = setTimeout(() => setDebounced(value), delayMs);
|
|
return () => clearTimeout(id);
|
|
}, [value, delayMs]);
|
|
|
|
return debounced;
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with a browser `window` event listener. The listener is
|
|
* removed before resubscribe and on unmount; the handler sees the latest render.
|
|
*/
|
|
export function useWindowEvent<K extends keyof WindowEventMap>(
|
|
type: K,
|
|
handler: (event: WindowEventMap[K]) => void,
|
|
options?: AddEventListenerOptions | boolean,
|
|
active = true,
|
|
): void {
|
|
const handlerRef = useRef(handler);
|
|
handlerRef.current = handler;
|
|
|
|
useEffect(() => {
|
|
if (!active || typeof window === "undefined") return undefined;
|
|
const listener = (event: WindowEventMap[K]) => handlerRef.current(event);
|
|
window.addEventListener(type, listener as EventListener, options);
|
|
return () => {
|
|
window.removeEventListener(type, listener as EventListener, options);
|
|
};
|
|
}, [active, options, type]);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with a browser `document` event listener. The listener is
|
|
* removed before resubscribe and on unmount; the handler sees the latest render.
|
|
*/
|
|
export function useDocumentEvent<K extends keyof DocumentEventMap>(
|
|
type: K,
|
|
handler: (event: DocumentEventMap[K]) => void,
|
|
options?: AddEventListenerOptions | boolean,
|
|
active = true,
|
|
): void {
|
|
const handlerRef = useRef(handler);
|
|
handlerRef.current = handler;
|
|
|
|
useEffect(() => {
|
|
if (!active || typeof document === "undefined") return undefined;
|
|
const listener = (event: DocumentEventMap[K]) => handlerRef.current(event);
|
|
document.addEventListener(type, listener as EventListener, options);
|
|
return () => {
|
|
document.removeEventListener(type, listener as EventListener, options);
|
|
};
|
|
}, [active, options, type]);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with `document.title`. The previous title is restored when
|
|
* the title changes or the component unmounts.
|
|
*/
|
|
export function useDocumentTitle(title: string): void {
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return undefined;
|
|
const previous = document.title;
|
|
document.title = title;
|
|
return () => {
|
|
document.title = previous;
|
|
};
|
|
}, [title]);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React rendering with a browser media query using
|
|
* `useSyncExternalStore`. The media query listener is removed on unsubscribe.
|
|
*/
|
|
export function useMediaQuery(query: string, serverSnapshot = false): boolean {
|
|
const subscribe = useCallback(
|
|
(onStoreChange: () => void) => {
|
|
if (typeof window === "undefined") return () => undefined;
|
|
const mediaQuery = window.matchMedia(query);
|
|
mediaQuery.addEventListener("change", onStoreChange);
|
|
return () => mediaQuery.removeEventListener("change", onStoreChange);
|
|
},
|
|
[query],
|
|
);
|
|
const getSnapshot = useCallback(
|
|
() => typeof window !== "undefined" && window.matchMedia(query).matches,
|
|
[query],
|
|
);
|
|
const getServerSnapshot = useCallback(
|
|
() => serverSnapshot,
|
|
[serverSnapshot],
|
|
);
|
|
|
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React rendering with `window.location.hash` using
|
|
* `useSyncExternalStore`. The `hashchange` listener is removed on unsubscribe.
|
|
*/
|
|
export function useLocationHash(serverSnapshot = ""): string {
|
|
const subscribe = useCallback((onStoreChange: () => void) => {
|
|
if (typeof window === "undefined") return () => undefined;
|
|
window.addEventListener("hashchange", onStoreChange);
|
|
return () => window.removeEventListener("hashchange", onStoreChange);
|
|
}, []);
|
|
const getSnapshot = useCallback(
|
|
() => typeof window === "undefined" ? serverSnapshot : window.location.hash,
|
|
[serverSnapshot],
|
|
);
|
|
const getServerSnapshot = useCallback(
|
|
() => serverSnapshot,
|
|
[serverSnapshot],
|
|
);
|
|
|
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
}
|
|
|
|
/**
|
|
* Synchronizes React with a browser `ResizeObserver`. The observer is
|
|
* disconnected before resubscribe and on unmount; the callback sees the latest
|
|
* render.
|
|
*/
|
|
export function useResizeObserver<T extends Element>(
|
|
ref: RefObject<T | null>,
|
|
callback: ResizeObserverCallback,
|
|
active = true,
|
|
): void {
|
|
const callbackRef = useRef(callback);
|
|
callbackRef.current = callback;
|
|
|
|
useEffect(() => {
|
|
if (!active || typeof ResizeObserver === "undefined") return undefined;
|
|
const node = ref.current;
|
|
if (!node) return undefined;
|
|
const observer = new ResizeObserver((entries, resizeObserver) => {
|
|
callbackRef.current(entries, resizeObserver);
|
|
});
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, [active, ref]);
|
|
}
|