authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-20 17:33:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 17:33:08-07:00
log051a4057a67ceebc7a36b13678f404b7ee871a46
treeba944aa4b2d7dda2c09e27fb5792d70f6d3761f1
parent2b178231ebfa597947efb4ec31bf60e0bb9e3128
signature Signed by PGP key B5690EEEBB952194

fix(web): device Reconnect starts one stream instead of two (#12808)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 37 insertions(+), 7 deletions(-)

apps/web/src/components/device/DeviceStreamView.test.tsx+35-6
...@@ -1,13 +1,25 @@...@@ -1,13 +1,25 @@
1import { act } from "react";1import { act, useSyncExternalStore } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { EnvironmentId } from "@t3tools/contracts";3import { EnvironmentId } from "@t3tools/contracts";
4import { afterEach, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
55
6// Like the real atom, a refresh yields a new access object and re-renders subscribers.
7const accessStore = {
8 value: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true },
9 listeners: new Set<() => void>(),
10 refresh() {
11 accessStore.value = { ...accessStore.value };
12 for (const listener of accessStore.listeners) listener();
13 },
14 subscribe(listener: () => void) {
15 accessStore.listeners.add(listener);
16 return () => accessStore.listeners.delete(listener);
17 },
18};
6vi.mock("~/state/device", () => ({19vi.mock("~/state/device", () => ({
7 useDeviceHubAccess: () => access,20 useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value),
8 refreshDeviceHubAccess: vi.fn(),21 refreshDeviceHubAccess: () => accessStore.refresh(),
9}));22}));
10const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };
11import { DeviceStreamView } from "./DeviceStreamView";23import { DeviceStreamView } from "./DeviceStreamView";
1224
13class Image extends EventTarget {25class Image extends EventTarget {
...@@ -19,6 +31,10 @@ class Image extends EventTarget {...@@ -19,6 +31,10 @@ class Image extends EventTarget {
19 }31 }
20}32}
21let renderer: ReactTestRenderer | undefined;33let renderer: ReactTestRenderer | undefined;
34let primes = 0;
35beforeEach(() => {
36 primes = 0;
37});
22afterEach(async () => {38afterEach(async () => {
23 await act(async () => renderer?.unmount());39 await act(async () => renderer?.unmount());
24 renderer = undefined;40 renderer = undefined;
...@@ -29,7 +45,10 @@ afterEach(async () => {...@@ -29,7 +45,10 @@ afterEach(async () => {
29async function setup() {45async function setup() {
30 vi.useFakeTimers();46 vi.useFakeTimers();
31 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);47 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
32 vi.stubGlobal("fetch", () => Promise.resolve(new Response("prime")));48 vi.stubGlobal("fetch", () => {
49 primes++;
50 return Promise.resolve(new Response("prime"));
51 });
33 vi.stubGlobal(52 vi.stubGlobal(
34 "WebSocket",53 "WebSocket",
35 class {54 class {
...@@ -106,3 +125,13 @@ it("offers Reconnect after the shared timeout and receives a frame after retry w...@@ -106,3 +125,13 @@ it("offers Reconnect after the shared timeout and receives a frame after retry w
106 expect(renderer!.root.findAllByType("button")).toHaveLength(0);125 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
107 expect(vi.getTimerCount()).toBe(0);126 expect(vi.getTimerCount()).toBe(0);
108});127});
128
129it("starts exactly one new stream per Reconnect press", async () => {
130 await setup();
131 await act(async () => {
132 await vi.advanceTimersByTimeAsync(15_000);
133 });
134 expect(primes).toBe(1);
135 await act(async () => renderer!.root.findByType("button").props.onClick());
136 expect(primes).toBe(2);
137});
apps/web/src/components/device/DeviceStreamView.tsx+2-1
...@@ -336,7 +336,8 @@ export function DeviceStreamView(props: {...@@ -336,7 +336,8 @@ export function DeviceStreamView(props: {
336 size="sm"336 size="sm"
337 variant="outline"337 variant="outline"
338 onClick={() => {338 onClick={() => {
339 refreshDeviceHubAccess(props.environmentId);339 // An expired ticket surfaces as unauthorized on restart and
340 // refreshes access through the effect; no need to mint one here.
340 clientRef.current?.stop();341 clientRef.current?.stop();
341 clientRef.current?.start();342 clientRef.current?.start();
342 }}343 }}