diff --git a/frontend/src/components/workspace/browser-view/use-browser-stream.ts b/frontend/src/components/workspace/browser-view/use-browser-stream.ts index 3992d0d4d..d7a858378 100644 --- a/frontend/src/components/workspace/browser-view/use-browser-stream.ts +++ b/frontend/src/components/workspace/browser-view/use-browser-stream.ts @@ -71,7 +71,11 @@ export function useBrowserStream( ); const [liveUrl, setLiveUrl] = useState(null); const [tabs, setTabs] = useState([]); - const [connectionAttempt, setConnectionAttempt] = useState(0); + // This state is only a lifecycle-generation signal. The actual consecutive + // reconnect count lives in a ref so resetting it after a successful open + // does not recreate the WebSocket effect. + const [reconnectGeneration, setReconnectGeneration] = useState(0); + const reconnectAttemptRef = useRef(0); const socketRef = useRef(null); const pendingNavigateRef = useRef= RECONNECT_MAX_ATTEMPTS) { + const attempt = reconnectAttemptRef.current; + if (attempt >= RECONNECT_MAX_ATTEMPTS) { return; } const delay = Math.min( - RECONNECT_BASE_DELAY_MS * 2 ** connectionAttempt, + RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS, ); reconnectTimer = window.setTimeout(() => { - setConnectionAttempt((attempt) => attempt + 1); + reconnectAttemptRef.current += 1; + setReconnectGeneration((generation) => generation + 1); }, delay); }; @@ -166,7 +173,7 @@ export function useBrowserStream( // mounted, so after RECONNECT_MAX_ATTEMPTS total reconnects — even across // many healthy connections — scheduleReconnect would bail forever and // Live would go permanently dead until the panel is toggled off/on. - setConnectionAttempt(0); + reconnectAttemptRef.current = 0; setStatus("open"); }; socket.onmessage = (message) => { @@ -229,7 +236,7 @@ export function useBrowserStream( socket.close(); frameBuffer.dispose(); }; - }, [connectionAttempt, enabled, frameBuffer, threadId]); + }, [reconnectGeneration, enabled, frameBuffer, threadId]); // Steer an already-open stream toward a changed seed in-band instead of // rebuilding the socket. Only navigates when the live page differs from the diff --git a/frontend/tests/unit/components/workspace/browser-view/use-browser-stream.dom.test.tsx b/frontend/tests/unit/components/workspace/browser-view/use-browser-stream.dom.test.tsx new file mode 100644 index 000000000..43c0b8939 --- /dev/null +++ b/frontend/tests/unit/components/workspace/browser-view/use-browser-stream.dom.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, rs, test } from "@rstest/core"; +import { act, cleanup, renderHook } from "@testing-library/react"; + +rs.mock("@/components/workspace/browser-view/api", () => ({ + browserStreamURL: (threadId: string) => `ws://example.test/${threadId}`, +})); + +import { useBrowserStream } from "@/components/workspace/browser-view/use-browser-stream"; + +class FakeWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + static instances: FakeWebSocket[] = []; + + readonly url: string; + readyState = 0; + binaryType = ""; + closeCalls = 0; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((message: MessageEvent) => void) | null = null; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send() { + return undefined; + } + + close() { + this.closeCalls += 1; + this.readyState = FakeWebSocket.CLOSED; + } + + open() { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + disconnect() { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.(); + } +} + +afterEach(() => { + cleanup(); + rs.useRealTimers(); + rs.restoreAllMocks(); + rs.unstubAllGlobals(); + FakeWebSocket.instances = []; +}); + +describe("useBrowserStream", () => { + test("keeps a successfully reconnected socket instead of recreating it", async () => { + rs.useFakeTimers(); + rs.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket); + + renderHook(() => useBrowserStream("thread-1", true)); + expect(FakeWebSocket.instances).toHaveLength(1); + + act(() => { + FakeWebSocket.instances[0]?.open(); + FakeWebSocket.instances[0]?.disconnect(); + }); + + act(() => { + void rs.advanceTimersByTime(800); + }); + expect(FakeWebSocket.instances).toHaveLength(2); + + act(() => { + FakeWebSocket.instances[1]?.open(); + }); + + expect(FakeWebSocket.instances).toHaveLength(2); + expect(FakeWebSocket.instances[1]?.closeCalls).toBe(0); + + act(() => { + FakeWebSocket.instances[1]?.disconnect(); + void rs.advanceTimersByTime(799); + }); + expect(FakeWebSocket.instances).toHaveLength(2); + + act(() => { + void rs.advanceTimersByTime(1); + }); + expect(FakeWebSocket.instances).toHaveLength(3); + }); +});