From 382fa0e55691dac1ecbececa7f6a38b712bdecbe Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:50:40 -0700 Subject: [PATCH] fix(web): device Reconnect starts one stream instead of two Reconnect refreshed hub access and also restarted the client by hand. The refresh produces a new access value that re-runs the effect, so the manual restart was torn down at once and the helper saw two connection attempts per press. Restart the client only; an expired ticket still refreshes access through the unauthorized path. Co-Authored-By: Claude Fable 5 --- .../device/DeviceStreamView.test.tsx | 41 ++++++++++++++++--- .../components/device/DeviceStreamView.tsx | 3 +- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx index 73d3bf11ee24..7e0392340129 100644 --- a/apps/web/src/components/device/DeviceStreamView.test.tsx +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -1,13 +1,25 @@ -import { act } from "react"; +import { act, useSyncExternalStore } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +// Like the real atom, a refresh yields a new access object and re-renders subscribers. +const accessStore = { + value: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }, + listeners: new Set<() => void>(), + refresh() { + accessStore.value = { ...accessStore.value }; + for (const listener of accessStore.listeners) listener(); + }, + subscribe(listener: () => void) { + accessStore.listeners.add(listener); + return () => accessStore.listeners.delete(listener); + }, +}; vi.mock("~/state/device", () => ({ - useDeviceHubAccess: () => access, - refreshDeviceHubAccess: vi.fn(), + useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value), + refreshDeviceHubAccess: () => accessStore.refresh(), })); -const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; import { DeviceStreamView } from "./DeviceStreamView"; class Image extends EventTarget { @@ -19,6 +31,10 @@ class Image extends EventTarget { } } let renderer: ReactTestRenderer | undefined; +let primes = 0; +beforeEach(() => { + primes = 0; +}); afterEach(async () => { await act(async () => renderer?.unmount()); renderer = undefined; @@ -29,7 +45,10 @@ afterEach(async () => { async function setup() { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime"))); + vi.stubGlobal("fetch", () => { + primes++; + return Promise.resolve(new Response("prime")); + }); vi.stubGlobal( "WebSocket", class { @@ -106,3 +125,13 @@ it("offers Reconnect after the shared timeout and receives a frame after retry w expect(renderer!.root.findAllByType("button")).toHaveLength(0); expect(vi.getTimerCount()).toBe(0); }); + +it("starts exactly one new stream per Reconnect press", async () => { + await setup(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + expect(primes).toBe(1); + await act(async () => renderer!.root.findByType("button").props.onClick()); + expect(primes).toBe(2); +}); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index 8078eac7ce11..4d4744a6268f 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -336,7 +336,8 @@ export function DeviceStreamView(props: { size="sm" variant="outline" onClick={() => { - refreshDeviceHubAccess(props.environmentId); + // An expired ticket surfaces as unauthorized on restart and + // refreshes access through the effect; no need to mint one here. clientRef.current?.stop(); clientRef.current?.start(); }}