fabro/apps/fabro-web/app/components/toast.test.tsx
fabro-sh-0530[bot] b196a97ac4
Introduce approved effect hooks and migrate direct useEffect calls (#425)
## 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>
2026-05-27 10:37:29 -04:00

214 lines
5.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { toast as sonnerToast, useSonner } from "sonner";
import { ToastProvider, useToast } from "./toast";
function textFromNode(node: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>): string {
if (!node) return "";
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textFromNode).join("");
return (node.children ?? []).map(textFromNode).join("");
}
function CaptureToastApi({
onReady,
}: {
onReady?: (api: ReturnType<typeof useToast>) => void;
}) {
const api = useToast();
onReady?.(api);
return null;
}
function SonnerToastText() {
const { toasts } = useSonner();
return (
<output aria-live="polite">
{toasts.map((toast) => (
<p key={toast.id}>
{typeof toast.title === "function" ? toast.title() : toast.title}
</p>
))}
</output>
);
}
async function flushSonnerUpdates() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("useToast", () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as { requestAnimationFrame?: (callback: FrameRequestCallback) => number }).requestAnimationFrame = (
callback,
) => setTimeout(callback, 0) as unknown as number;
});
afterEach(() => {
sonnerToast.dismiss();
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
delete (globalThis as { requestAnimationFrame?: (callback: FrameRequestCallback) => number }).requestAnimationFrame;
});
test("push renders a Sonner toast with the message", async () => {
let api: ReturnType<typeof useToast> | null = null;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<>
<SonnerToastText />
<CaptureToastApi
onReady={(value) => {
api = value;
}}
/>
</>,
);
});
await act(async () => {
api!.push({ message: "Run archived." });
});
await flushSonnerUpdates();
expect(textFromNode(renderer!.toJSON())).toContain("Run archived.");
await act(async () => {
renderer?.unmount();
});
});
test("error toasts are red and persistent", async () => {
let api: ReturnType<typeof useToast> | null = null;
let toastId = "";
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<>
<SonnerToastText />
<CaptureToastApi
onReady={(value) => {
api = value;
}}
/>
</>,
);
});
await act(async () => {
toastId = api!.push({ message: "Conflict", tone: "error", autoDismissMs: 5 });
});
await flushSonnerUpdates();
expect(textFromNode(renderer!.toJSON())).toContain("Conflict");
expect(
sonnerToast.getToasts().find((toast) => toast.id === toastId),
).toMatchObject({
duration: Infinity,
title: "Conflict",
type: "error",
});
await act(async () => {
renderer?.unmount();
});
});
test("dismiss removes one toast from the Sonner store", async () => {
let api: ReturnType<typeof useToast> | null = null;
let secondId = "";
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<>
<SonnerToastText />
<CaptureToastApi
onReady={(value) => {
api = value;
}}
/>
</>,
);
});
await act(async () => {
api!.push({ message: "First" });
secondId = api!.push({ message: "Second" });
});
await flushSonnerUpdates();
await act(async () => {
api!.dismiss(secondId);
});
await flushSonnerUpdates();
const text = textFromNode(renderer!.toJSON());
expect(text).toContain("First");
expect(text).not.toContain("Second");
await act(async () => {
renderer?.unmount();
});
});
test("clear removes all Sonner toasts", async () => {
let api: ReturnType<typeof useToast> | null = null;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider>
<SonnerToastText />
<CaptureToastApi
onReady={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
});
await act(async () => {
api!.push({ message: "First" });
api!.push({ message: "Second" });
});
await flushSonnerUpdates();
await act(async () => {
api!.clear();
});
await flushSonnerUpdates();
const text = textFromNode(renderer!.toJSON());
expect(text).not.toContain("First");
expect(text).not.toContain("Second");
await act(async () => {
renderer?.unmount();
});
});
test("ToastProvider is transparent for existing test wrappers", async () => {
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(
<ToastProvider>
<span>wrapped child</span>
</ToastProvider>,
);
});
expect(textFromNode(renderer!.toJSON())).toContain("wrapped child");
await act(async () => {
renderer?.unmount();
});
});
});