fix(frontend): bound Live Browser WebSocket handshakes (#7301)

* fix(frontend): bound Live Browser WebSocket handshakes

* test: register browser-view scoped guidance

* fix(frontend): explain Browser Live connection failures

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
EastonJiang 2026-10-23 15:39:05 +08:00 committed by GitHub
parent 1278c5192b
commit 0d3a562c5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 555 additions and 26 deletions

View File

@ -2960,6 +2960,11 @@ The workspace Browser Live client negotiates binary JPEG WebSocket frames,
keeps only the newest pending frame per display refresh, and revokes replaced
object URLs. Gateway control messages remain JSON, and clients that do not
request the binary capability retain the legacy JSON/base64 frame protocol.
Opening a Live connection has a 10-second handshake deadline. A stalled
handshake enters the existing capped backoff, with up to six reconnects;
successful connections reset that retry budget. The panel distinguishes connection
timeouts from other connection failures and shows recovery guidance, including
when retries are exhausted. Turn Live off and on to start a fresh retry budget.
### Context Engineering

View File

@ -53,6 +53,7 @@ EXPECTED_GUIDANCE_PATHS = {
"backend/packages/harness/deerflow/uploads/AGENTS.md",
"backend/packages/harness/deerflow/utils/AGENTS.md",
"frontend/src/AGENTS.md",
"frontend/src/components/workspace/browser-view/AGENTS.md",
"frontend/src/core/memory/AGENTS.md",
"frontend/tests/e2e/AGENTS.md",
"scripts/AGENTS.md",

View File

@ -0,0 +1,18 @@
# Browser Live
`useBrowserStream` owns a 10-second opening-handshake deadline per socket
attempt. Timeout retires that attempt and schedules the existing bounded
backoff without depending on a later socket event. Open/error/close and effect
cleanup clear the deadline; retired callbacks cannot reset the retry budget,
update frames, or accept input. Keep the deadline limited to CONNECTING; an
established quiet stream has no client idle timeout.
Expose `closedReason` as `handshake_timeout` or `connection_failed`; WebSocket
error/close events cannot reliably identify auth, origin, or network failures.
Keep the latest failure reason visible during retries; fence retired callbacks;
clear it on open, disable, or thread change. The panel localizes the reason and
recovery hint even over a retained frame; exhausted retries must not look pending.
Lifecycle regressions live in
`frontend/tests/unit/components/workspace/browser-view/use-browser-stream.dom.test.tsx`;
panel feedback is covered by the adjacent `browser-view-panel.dom.test.tsx`.

View File

@ -16,6 +16,7 @@ import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
@ -31,6 +32,7 @@ export function BrowserViewPanel({
threadId: string;
className?: string;
}) {
const { t } = useI18n();
const browserView = useMaybeBrowserView();
const frame = browserView?.latestFrame ?? null;
const imageUrl = frame
@ -57,12 +59,8 @@ export function BrowserViewPanel({
},
[],
);
const { status, frameUrl, liveUrl, sendInput } = useBrowserStream(
threadId,
live,
streamSeedUrl,
handleNavRejected,
);
const { status, closedReason, frameUrl, liveUrl, sendInput } =
useBrowserStream(threadId, live, streamSeedUrl, handleNavRejected);
const panelRef = useRef<HTMLDivElement | null>(null);
const surfaceRef = useRef<HTMLImageElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
@ -240,6 +238,12 @@ export function BrowserViewPanel({
}
};
const liveFailureMessage =
live && closedReason
? closedReason === "handshake_timeout"
? t.browserStream.handshakeTimeout
: t.browserStream.connectionFailed
: null;
const liveActive = live && status === "open";
const liveConnecting = live && status === "connecting";
const displayUrl = live
@ -407,7 +411,11 @@ export function BrowserViewPanel({
title={live ? "Stop live control" : "Take live control"}
>
<RadioIcon className="size-3.5" />
{live ? (status === "open" ? "Live" : "…") : "Live"}
{live && status !== "open"
? status === "closed"
? t.browserStream.disconnected
: t.browserStream.connecting
: "Live"}
</Button>
<Button
size="icon-sm"
@ -422,6 +430,14 @@ export function BrowserViewPanel({
</Button>
</header>
<main className="relative flex min-h-0 grow flex-col overflow-hidden bg-neutral-950">
{liveFailureMessage && (
<p
role="status"
className="bg-background shrink-0 border-b px-3 py-2 text-xs"
>
{liveFailureMessage}
</p>
)}
<div
ref={stageRef}
className="relative min-h-0 grow bg-neutral-900"
@ -443,12 +459,18 @@ export function BrowserViewPanel({
className="absolute inset-0 m-auto h-fit"
icon={<MonitorIcon />}
title={
live ? "Connecting to live browser…" : "No browser activity yet"
liveFailureMessage
? t.browserStream.disconnected
: live
? "Connecting to live browser…"
: "No browser activity yet"
}
description={
live
? "Waiting for the first live frame."
: "Enter a URL above or let the agent browse — the live view will appear here."
liveFailureMessage
? ""
: live
? "Waiting for the first live frame."
: "Enter a URL above or let the agent browse — the live view will appear here."
}
/>
)}

View File

@ -10,6 +10,7 @@ export {
type BrowserTab,
type BrowserInputEvent,
type BrowserStreamStatus,
type BrowserStreamClosedReason,
} from "./use-browser-stream";
export {
BrowserViewProvider,

View File

@ -32,7 +32,11 @@ export type BrowserInputEvent =
| { type: "activate_tab"; index: number };
export type BrowserStreamStatus = "idle" | "connecting" | "open" | "closed";
export type BrowserStreamClosedReason =
| "handshake_timeout"
| "connection_failed";
const HANDSHAKE_TIMEOUT_MS = 10_000;
const RECONNECT_BASE_DELAY_MS = 800;
const RECONNECT_MAX_DELAY_MS = 10_000;
const RECONNECT_MAX_ATTEMPTS = 6;
@ -63,6 +67,8 @@ export function useBrowserStream(
) => void,
) {
const [status, setStatus] = useState<BrowserStreamStatus>("idle");
const [closedReason, setClosedReason] =
useState<BrowserStreamClosedReason | null>(null);
const [frameBuffer] = useState(() => new LatestBrowserFrameBuffer());
const frameUrl = useSyncExternalStore(
frameBuffer.subscribe,
@ -106,6 +112,7 @@ export function useBrowserStream(
useEffect(() => {
pendingNavigateRef.current = null;
setClosedReason(null);
}, [threadId]);
useEffect(() => {
@ -123,11 +130,14 @@ export function useBrowserStream(
useEffect(() => {
if (!enabled) {
setStatus("idle");
setClosedReason(null);
liveUrlRef.current = null;
return;
}
let closedByEffect = false;
let attemptClosed = false;
let handshakeTimer: number | null = null;
let reconnectTimer: number | null = null;
setStatus("connecting");
// browserStreamURL treats empty/undefined seed identically (no seed param),
@ -139,6 +149,22 @@ export function useBrowserStream(
socket.binaryType = "blob";
socketRef.current = socket;
const clearHandshakeTimer = () => {
if (handshakeTimer !== null) {
window.clearTimeout(handshakeTimer);
handshakeTimer = null;
}
};
const closeSocket = () => {
if (
socket.readyState === WebSocket.CONNECTING ||
socket.readyState === WebSocket.OPEN
) {
socket.close();
}
};
const scheduleReconnect = () => {
if (closedByEffect || !enabled) {
return;
@ -162,7 +188,21 @@ export function useBrowserStream(
}, delay);
};
const closeAttempt = (reason: BrowserStreamClosedReason) => {
if (closedByEffect || attemptClosed) return;
// Fence all callbacks before close() can emit another event. A retired
// attempt must not accept input or reset the reconnect budget on late open.
attemptClosed = true;
clearHandshakeTimer();
socketRef.current = null;
setClosedReason(reason);
setStatus("closed");
scheduleReconnect();
};
socket.onopen = () => {
if (closedByEffect || attemptClosed) return;
clearHandshakeTimer();
const pendingNavigate = pendingNavigateRef.current;
if (pendingNavigate) {
socket.send(JSON.stringify(pendingNavigate));
@ -174,11 +214,12 @@ export function useBrowserStream(
// many healthy connections — scheduleReconnect would bail forever and
// Live would go permanently dead until the panel is toggled off/on.
reconnectAttemptRef.current = 0;
setClosedReason(null);
setStatus("open");
};
socket.onmessage = (message) => {
try {
if (closedByEffect) return;
if (closedByEffect || attemptClosed) return;
if (message.data instanceof Blob) {
frameBuffer.push(
message.data.type === "image/jpeg"
@ -214,26 +255,33 @@ export function useBrowserStream(
console.warn("Ignoring malformed browser stream message", error);
}
};
socket.onclose = () => {
if (!closedByEffect) {
setStatus("closed");
scheduleReconnect();
// WebSocket events do not reliably disclose whether an upgrade was refused
// by auth, an origin policy, or the network. Keep this reason generic.
socket.onclose = () => closeAttempt("connection_failed");
socket.onerror = () => closeAttempt("connection_failed");
handshakeTimer = window.setTimeout(() => {
if (
closedByEffect ||
attemptClosed ||
socket.readyState !== WebSocket.CONNECTING
) {
return;
}
};
socket.onerror = () => {
if (!closedByEffect) {
setStatus("closed");
scheduleReconnect();
}
};
// A stalled upgrade may never emit error/close, even after close() is
// requested. Enter the bounded reconnect path without waiting for either.
closeAttempt("handshake_timeout");
closeSocket();
}, HANDSHAKE_TIMEOUT_MS);
return () => {
closedByEffect = true;
clearHandshakeTimer();
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
socketRef.current = null;
socket.close();
closeSocket();
frameBuffer.dispose();
};
}, [reconnectGeneration, enabled, frameBuffer, threadId]);
@ -256,5 +304,5 @@ export function useBrowserStream(
sendInput({ type: "navigate", url: target });
}, [enabled, seedUrl, sendInput, status]);
return { status, frameUrl, liveUrl, tabs, sendInput };
return { status, closedReason, frameUrl, liveUrl, tabs, sendInput };
}

View File

@ -245,6 +245,15 @@ export const enUS: Translations = {
showBrowser: "Open browser panel",
},
browserStream: {
connecting: "Connecting…",
disconnected: "Disconnected",
handshakeTimeout:
"Live browser connection timed out. Check your network or proxy, then turn Live off and on to retry.",
connectionFailed:
"Live browser connection failed or closed. Check your connection and access permissions, then turn Live off and on to retry.",
},
skillUsage: {
used: "Skills used",
title: "Skills",

View File

@ -177,6 +177,13 @@ export interface Translations {
showBrowser: string;
};
browserStream: {
connecting: string;
disconnected: string;
handshakeTimeout: string;
connectionFailed: string;
};
skillUsage: {
used: string;
title: string;

View File

@ -233,6 +233,15 @@ export const zhCN: Translations = {
showBrowser: "打开浏览器面板",
},
browserStream: {
connecting: "正在连接…",
disconnected: "连接已断开",
handshakeTimeout:
"实时浏览器连接超时。请检查网络或代理设置,然后关闭并重新开启 Live 以重试。",
connectionFailed:
"实时浏览器连接失败或已关闭。请检查网络连接和访问权限,然后关闭并重新开启 Live 以重试。",
},
skillUsage: {
used: "使用的技能",
title: "技能",

View File

@ -0,0 +1,97 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { BrowserViewPanel } from "@/components/workspace/browser-view/browser-view-panel";
import { enUS } from "@/core/i18n/locales/en-US";
import type { Translations } from "@/core/i18n/locales/types";
import { zhCN } from "@/core/i18n/locales/zh-CN";
const { stream } = rs.hoisted(() => ({
stream: {
status: "closed" as "idle" | "connecting" | "open" | "closed",
closedReason: "handshake_timeout" as
| "handshake_timeout"
| "connection_failed"
| null,
frameUrl: null as string | null,
liveUrl: null,
sendInput: rs.fn(),
},
}));
let translations: Translations = enUS;
rs.mock("@/components/workspace/browser-view/use-browser-stream", () => ({
useBrowserStream: () => stream,
}));
rs.mock("@/components/workspace/browser-view/context", () => ({
useMaybeBrowserView: () => null,
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ t: translations }),
}));
afterEach(() => {
cleanup();
stream.status = "closed";
stream.closedReason = "handshake_timeout";
stream.frameUrl = null;
translations = enUS;
});
for (const [locale, dictionary] of [
["en-US", enUS],
["zh-CN", zhCN],
] as const) {
test.each([false, true])(
`shows actionable ${locale} failures with a previous frame: %s`,
(hasFrame) => {
translations = dictionary;
stream.frameUrl = hasFrame ? "data:image/jpeg;base64,AA==" : null;
const view = render(<BrowserViewPanel threadId="thread-1" />);
expect(screen.getByRole("status").textContent).toBe(
dictionary.browserStream.handshakeTimeout,
);
expect(
screen.getByRole("button", {
name: dictionary.browserStream.disconnected,
}),
).toBeTruthy();
expect(screen.queryByText("Connecting to live browser…")).toBeNull();
if (hasFrame) expect(screen.getByAltText("Browser view")).toBeTruthy();
stream.closedReason = "connection_failed";
view.rerender(<BrowserViewPanel threadId="thread-1" />);
expect(screen.getByRole("status").textContent).toBe(
dictionary.browserStream.connectionFailed,
);
stream.status = "connecting";
stream.frameUrl = null;
view.rerender(<BrowserViewPanel threadId="thread-1" />);
expect(screen.getByRole("status").textContent).toBe(
dictionary.browserStream.connectionFailed,
);
expect(
screen.getByRole("button", {
name: dictionary.browserStream.connecting,
}),
).toBeTruthy();
if (hasFrame) expect(screen.getByAltText("Browser view")).toBeTruthy();
stream.status = "open";
stream.closedReason = null;
view.rerender(<BrowserViewPanel threadId="thread-1" />);
expect(screen.queryByRole("status")).toBeNull();
expect(screen.getByRole("button", { name: "Live" })).toBeTruthy();
},
);
}
test("hides the failure notice when live control is turned off", () => {
render(<BrowserViewPanel threadId="thread-1" />);
expect(screen.getByRole("status")).toBeTruthy();
fireEvent.click(screen.getByTitle("Stop live control"));
expect(screen.queryByRole("status")).toBeNull();
expect(screen.getByTitle("Take live control")).toBeTruthy();
});

View File

@ -8,12 +8,13 @@ rs.mock("@/components/workspace/browser-view/api", () => ({
import { useBrowserStream } from "@/components/workspace/browser-view/use-browser-stream";
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSED = 3;
static instances: FakeWebSocket[] = [];
readonly url: string;
readyState = 0;
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
closeCalls = 0;
onopen: (() => void) | null = null;
@ -55,6 +56,317 @@ afterEach(() => {
});
describe("useBrowserStream", () => {
// Regression coverage for bytedance/deer-flow#7296.
test("times out a silent handshake and reconnects after the existing backoff", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const first = FakeWebSocket.instances[0]!;
act(() => {
void rs.advanceTimersByTime(9_999);
});
expect(first.closeCalls).toBe(0);
expect(result.current.status).toBe("connecting");
act(() => {
void rs.advanceTimersByTime(1);
});
expect(first.closeCalls).toBe(1);
expect(result.current.status).toBe("closed");
expect(FakeWebSocket.instances).toHaveLength(1);
// close() deliberately emits no event: timeout recovery must not depend
// on an eventual error/close event from the failed handshake.
act(() => {
void rs.advanceTimersByTime(799);
});
expect(FakeWebSocket.instances).toHaveLength(1);
act(() => {
void rs.advanceTimersByTime(1);
});
expect(FakeWebSocket.instances).toHaveLength(2);
expect(result.current.status).toBe("connecting");
});
test("clears the handshake deadline after a successful open", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const first = FakeWebSocket.instances[0]!;
act(() => {
void rs.advanceTimersByTime(9_999);
first.open();
});
expect(rs.getTimerCount()).toBe(0);
act(() => {
void rs.advanceTimersByTime(300_000);
});
expect(result.current.status).toBe("open");
expect(first.closeCalls).toBe(0);
expect(FakeWebSocket.instances).toHaveLength(1);
});
test("failure events share one reconnect and clear the failed handshake deadline", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const first = FakeWebSocket.instances[0]!;
act(() => {
void rs.advanceTimersByTime(1_000);
first.onerror?.();
first.disconnect();
});
expect(rs.getTimerCount()).toBe(1);
act(() => {
void rs.advanceTimersByTime(800);
});
expect(FakeWebSocket.instances).toHaveLength(2);
const replacement = FakeWebSocket.instances[1]!;
act(() => {
replacement.open();
void rs.advanceTimersByTime(300_000);
});
expect(rs.getTimerCount()).toBe(0);
expect(result.current.status).toBe("open");
expect(replacement.closeCalls).toBe(0);
expect(FakeWebSocket.instances).toHaveLength(2);
});
test("clears the handshake deadline when the panel is disabled", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result, rerender } = renderHook(
({ enabled }) => useBrowserStream("thread-1", enabled),
{ initialProps: { enabled: true } },
);
act(() => {
void rs.advanceTimersByTime(5_000);
});
rerender({ enabled: false });
expect(rs.getTimerCount()).toBe(0);
act(() => {
void rs.advanceTimersByTime(60_000);
});
expect(result.current.status).toBe("idle");
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0]!.closeCalls).toBe(1);
});
test("clears the handshake deadline on unmount", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { unmount } = renderHook(() => useBrowserStream("thread-1", true));
act(() => {
void rs.advanceTimersByTime(5_000);
});
unmount();
expect(rs.getTimerCount()).toBe(0);
act(() => {
void rs.advanceTimersByTime(60_000);
});
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0]!.closeCalls).toBe(1);
});
test("a thread change gives the new socket its own deadline and ignores stale events", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result, rerender } = renderHook(
({ threadId }) => useBrowserStream(threadId, true),
{ initialProps: { threadId: "thread-1" } },
);
const first = FakeWebSocket.instances[0]!;
act(() => {
void rs.advanceTimersByTime(5_000);
});
rerender({ threadId: "thread-2" });
const replacement = FakeWebSocket.instances[1]!;
expect(first.closeCalls).toBe(1);
expect(replacement.url).toBe("ws://example.test/thread-2");
act(() => {
first.open();
first.onerror?.();
first.disconnect();
void rs.advanceTimersByTime(5_000);
});
expect(result.current.status).toBe("connecting");
expect(replacement.closeCalls).toBe(0);
expect(FakeWebSocket.instances).toHaveLength(2);
act(() => {
replacement.open();
void rs.advanceTimersByTime(300_000);
});
expect(result.current.status).toBe("open");
expect(replacement.closeCalls).toBe(0);
expect(rs.getTimerCount()).toBe(0);
});
test("ignores late open, frame, error and close events from a timed-out attempt", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const first = FakeWebSocket.instances[0]!;
act(() => {
void rs.advanceTimersByTime(10_000);
});
expect(first.closeCalls).toBe(1);
act(() => {
first.open();
first.onmessage?.({
data: JSON.stringify({ type: "url", url: "https://stale.example/" }),
} as MessageEvent);
first.onerror?.();
first.disconnect();
});
expect(result.current.status).toBe("closed");
expect(result.current.liveUrl).toBeNull();
expect(rs.getTimerCount()).toBe(1);
act(() => {
void rs.advanceTimersByTime(800);
});
const replacement = FakeWebSocket.instances[1]!;
act(() => {
replacement.open();
first.open();
first.onerror?.();
first.disconnect();
void rs.advanceTimersByTime(300_000);
});
expect(result.current.status).toBe("open");
expect(replacement.closeCalls).toBe(0);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(rs.getTimerCount()).toBe(0);
});
test("stalled handshakes stop after the existing six-reconnect limit", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
for (let attempt = 0; attempt <= 6; attempt += 1) {
const socket = FakeWebSocket.instances[attempt]!;
act(() => {
void rs.advanceTimersByTime(10_000);
});
expect(socket.closeCalls).toBe(1);
expect(result.current.status).toBe("closed");
act(() => {
// A late open must not replenish a nonzero retry budget.
socket.open();
socket.disconnect();
});
expect(result.current.status).toBe("closed");
if (attempt < 6) {
const delay = Math.min(800 * 2 ** attempt, 10_000);
act(() => {
void rs.advanceTimersByTime(delay);
});
expect(FakeWebSocket.instances).toHaveLength(attempt + 2);
}
}
act(() => {
void rs.advanceTimersByTime(300_000);
});
expect(FakeWebSocket.instances).toHaveLength(7);
expect(result.current.closedReason).toBe("handshake_timeout");
expect(result.current.status).toBe("closed");
expect(rs.getTimerCount()).toBe(0);
});
test("preserves a timeout reason through retries and stale events until open", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const first = FakeWebSocket.instances[0]!;
expect(result.current.closedReason).toBeNull();
act(() => {
void rs.advanceTimersByTime(10_000);
first.onerror?.();
first.disconnect();
first.open();
});
expect(result.current.closedReason).toBe("handshake_timeout");
act(() => {
void rs.advanceTimersByTime(800);
first.onerror?.();
});
expect(result.current.status).toBe("connecting");
expect(result.current.closedReason).toBe("handshake_timeout");
act(() => FakeWebSocket.instances[1]!.open());
expect(result.current.status).toBe("open");
expect(result.current.closedReason).toBeNull();
});
test.each(["error", "close"] as const)(
"reports a socket %s separately from a handshake timeout",
(event) => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result } = renderHook(() => useBrowserStream("thread-1", true));
const socket = FakeWebSocket.instances[0]!;
act(() => {
if (event === "error") socket.onerror?.();
else socket.disconnect();
});
expect(result.current.status).toBe("closed");
expect(result.current.closedReason).toBe("connection_failed");
act(() => {
void rs.advanceTimersByTime(800);
});
act(() => FakeWebSocket.instances[1]!.open());
expect(result.current.closedReason).toBeNull();
},
);
test("clears the failure reason when disabled or moved to another thread", () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
const { result, rerender } = renderHook(
({ threadId, enabled }) => useBrowserStream(threadId, enabled),
{ initialProps: { threadId: "thread-1", enabled: true } },
);
act(() => {
void rs.advanceTimersByTime(10_000);
});
expect(result.current.closedReason).toBe("handshake_timeout");
rerender({ threadId: "thread-1", enabled: false });
expect(result.current.closedReason).toBeNull();
rerender({ threadId: "thread-1", enabled: true });
expect(result.current.closedReason).toBeNull();
act(() => {
void rs.advanceTimersByTime(10_000);
});
expect(result.current.closedReason).toBe("handshake_timeout");
const retired = FakeWebSocket.instances.at(-1)!;
rerender({ threadId: "thread-2", enabled: true });
act(() => retired.onerror?.());
expect(result.current.status).toBe("connecting");
expect(result.current.closedReason).toBeNull();
});
test("keeps a successfully reconnected socket instead of recreating it", async () => {
rs.useFakeTimers();
rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);