fabro/apps/fabro-web/app/hooks/effects.ts
André Mazoni c5dd5772d0
Keep run graph zoom/pan when switching tabs (#561)
Switching from a run's Overview tab to another tab and back reset the
graph zoom and position to the default. Now it holds.

## Why

The viewport (pan and zoom) lived in `RunOverview` component state.
Overview and Stages are sibling routes under `runs/:id`, so switching
tabs unmounts Overview and drops that state.

## Fix

`apps/fabro-web/app/routes/run-overview.tsx`: cache the viewport per run
outside the component so it survives the remount, and reset it when the
run id changes, since the route instance is reused when only the id
changes.

Added two tests: viewport restores on remount for the same run, and does
not carry across runs.

Does not persist across a full page reload (in-memory only).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-07-08 10:50:02 -04:00

247 lines
7.8 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;
}
/**
* Shared body of the event-listener hooks below. The target is read when the
* effect runs (so `window`/`document` absence and not-yet-mounted elements are
* handled uniformly); the listener is removed before resubscribe and on unmount;
* the handler sees the latest render without forcing a resubscribe. Callers must
* pass a stable `options` value — a fresh object every render resubscribes the
* listener every render.
*/
function useEventTargetEvent<E extends Event>(
target: { readonly current: EventTarget | null },
type: string,
handler: (event: E) => void,
options: AddEventListenerOptions | boolean | undefined,
active: boolean,
): void {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const el = target.current;
if (!active || !el) return undefined;
const listener: EventListener = (event) => handlerRef.current(event as E);
el.addEventListener(type, listener, options);
return () => {
el.removeEventListener(type, listener, options);
};
}, [active, options, target, type]);
}
// Lazy getters so `window`/`document` are only touched when an effect runs, never
// at module load (matching the previous per-hook `typeof window` checks).
const WINDOW_TARGET = {
get current(): EventTarget | null {
return typeof window === "undefined" ? null : window;
},
};
const DOCUMENT_TARGET = {
get current(): EventTarget | null {
return typeof document === "undefined" ? null : document;
},
};
/**
* 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 {
useEventTargetEvent(WINDOW_TARGET, type, handler, options, active);
}
/**
* 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 {
useEventTargetEvent(DOCUMENT_TARGET, type, handler, options, active);
}
/**
* Synchronizes React with an event listener on a ref'd element. The listener is
* removed before resubscribe and on unmount; the handler sees the latest render.
* Unlike a JSX event prop, this can pass `{ passive: false }` so the handler may
* `preventDefault()` (e.g. to own wheel/⌘-scroll instead of the browser).
*
* The element must exist when the effect runs: if it renders conditionally, gate
* with `active` on the same condition so the effect re-runs once it mounts.
*/
export function useElementEvent<K extends keyof HTMLElementEventMap>(
ref: RefObject<HTMLElement | null>,
type: K,
handler: (event: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions | boolean,
active = true,
): void {
useEventTargetEvent(ref, type, handler, options, active);
}
/**
* 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]);
}