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 @@
1import { act } from "react";
1import { act, useSyncExternalStore } from "react";
22import { create, type ReactTestRenderer } from "react-test-renderer";
33import { 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};
619vi.mock("~/state/device", () => ({
7 useDeviceHubAccess: () => access,
8 refreshDeviceHubAccess: vi.fn(),
20 useDeviceHubAccess: () => useSyncExternalStore(accessStore.subscribe, () => accessStore.value),
21 refreshDeviceHubAccess: () => accessStore.refresh(),
922}));
10const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true };
1123import { DeviceStreamView } from "./DeviceStreamView";
1224
1325class Image extends EventTarget {
......@@ -19,6 +31,10 @@ class Image extends EventTarget {
1931 }
2032}
2133let renderer: ReactTestRenderer | undefined;
34let primes = 0;
35beforeEach(() => {
36 primes = 0;
37});
2238afterEach(async () => {
2339 await act(async () => renderer?.unmount());
2440 renderer = undefined;
......@@ -29,7 +45,10 @@ afterEach(async () => {
2945async function setup() {
3046 vi.useFakeTimers();
3147 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 });
3352 vi.stubGlobal(
3453 "WebSocket",
3554 class {
......@@ -106,3 +125,13 @@ it("offers Reconnect after the shared timeout and receives a frame after retry w
106125 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
107126 expect(vi.getTimerCount()).toBe(0);
108127});
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: {
336336 size="sm"
337337 variant="outline"
338338 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.
340341 clientRef.current?.stop();
341342 clientRef.current?.start();
342343 }}