From b4f69b75f2e60ebf4255dec1e0b454e3b0ce189d Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 27 Oct 2026 01:12:33 +0000 Subject: [PATCH 1/2] Settings Wi-Fi and Bluetooth panels: auto-refresh every 10s while open - Move fetch logic into load() function inside component - Run load() on mount, then setInterval(load, 10000) with cleanup - Use local mounted variable (not ref) to handle React 18 strict mode - On success, clear error state (setError(null)); on failure after good load, set error - Add tests using vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) verifying refresh shows new data and unmount stops further fetches --- .../tsk-br5bqw-hw-panels-live-refresh.md | 2 + .../apps/SettingsApp/BluetoothPanel.test.tsx | 38 +++++++++++++++++- .../src/apps/SettingsApp/BluetoothPanel.tsx | 20 ++++++---- .../src/apps/SettingsApp/WifiPanel.test.tsx | 40 ++++++++++++++++++- desktop/src/apps/SettingsApp/WifiPanel.tsx | 20 ++++++---- 5 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 changelog.d/tsk-br5bqw-hw-panels-live-refresh.md diff --git a/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md b/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md new file mode 100644 index 000000000..05263b660 --- /dev/null +++ b/changelog.d/tsk-br5bqw-hw-panels-live-refresh.md @@ -0,0 +1,2 @@ +### Changed +- Wi-Fi and Bluetooth settings panels now auto-refresh every 10 seconds while open, showing live device/AP changes without reopening Settings. \ No newline at end of file diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx index c187fe4ae..bc6aff386 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { BluetoothPanel } from "./BluetoothPanel"; function json(obj: unknown, status = 200) { @@ -61,4 +61,38 @@ describe("BluetoothPanel", () => { render(); expect(await screen.findByRole("alert")).toBeInTheDocument(); }); + + it("refreshes devices every 10s and clears interval on unmount", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy + .mockResolvedValueOnce( + json({ + available: true, + adapters: [{ path: "/org/bluez/hci0", address: "AA:BB:CC:DD:EE:FF", powered: true }], + devices: [{ path: "/org/bluez/hci0/dev_11_22_33_44_55_66", address: "11:22:33:44:55:66", name: "FirstDevice", paired: true, connected: true, trusted: true }], + }) + ) + .mockResolvedValueOnce( + json({ + available: true, + adapters: [{ path: "/org/bluez/hci0", address: "AA:BB:CC:DD:EE:FF", powered: true }], + devices: [{ path: "/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF", address: "AA:BB:CC:DD:EE:FF", name: "SecondDevice", paired: true, connected: true, trusted: true }], + }) + ); + const { unmount } = render(); + await waitFor(() => expect(screen.getByText("FirstDevice")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await waitFor(() => expect(screen.getByText("SecondDevice")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + unmount(); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }, 15000); }); diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx index 24f992ba5..7e2f35abb 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { Card } from "@/components/ui"; export function BluetoothPanel() { @@ -6,15 +6,14 @@ export function BluetoothPanel() { const [adapters, setAdapters] = useState<{ path: string; address: string; powered: boolean }[]>([]); const [devices, setDevices] = useState<{ path: string; address: string; name: string | null; paired: boolean; connected: boolean; trusted: boolean }[]>([]); const [error, setError] = useState(null); - const mounted = useRef(true); - useEffect(() => () => { mounted.current = false; }, []); useEffect(() => { - (async () => { + let mounted = true; + const load = async () => { try { const res = await fetch("/api/hardware/bluetooth", { credentials: "include" }); const body = await res.json().catch(() => null); - if (!mounted.current) return; + if (!mounted) return; if (!res.ok || body === null || typeof body.available !== "boolean") { setError("Could not load Bluetooth state."); return; @@ -26,10 +25,17 @@ export function BluetoothPanel() { if (Array.isArray(body.devices)) { setDevices(body.devices); } + setError(null); } catch { - if (mounted.current) setError("Could not reach taOS."); + if (mounted) setError("Could not reach taOS."); } - })(); + }; + load(); + const id = setInterval(load, 10_000); + return () => { + mounted = false; + clearInterval(id); + }; }, []); return ( diff --git a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx index 7613fab29..2c41f23a7 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; import { WifiPanel } from "./WifiPanel"; function json(obj: unknown, status = 200) { @@ -56,4 +56,40 @@ describe("WifiPanel", () => { expect(await screen.findByRole("alert")).toHaveTextContent("Could not load Wi-Fi state."); expect(screen.queryByRole("list", { name: "Wi-Fi networks" })).not.toBeInTheDocument(); }); + +it("refreshes access points every 10s and clears interval on unmount", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + fetchSpy + .mockResolvedValueOnce( + json({ + available: true, + wifi_enabled: true, + devices: [], + access_points: [{ ssid: "FirstAP", strength: 50, frequency: 2412, secured: true, active: false }], + }) + ) + .mockResolvedValueOnce( + json({ + available: true, + wifi_enabled: true, + devices: [], + access_points: [{ ssid: "SecondAP", strength: 75, frequency: 2437, secured: true, active: true }], + }) + ); + const { unmount } = render(); + await waitFor(() => expect(screen.getByText("FirstAP")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + await waitFor(() => expect(screen.getByText("SecondAP")).toBeInTheDocument(), { timeout: 3000 }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + unmount(); + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }, 15000); }); \ No newline at end of file diff --git a/desktop/src/apps/SettingsApp/WifiPanel.tsx b/desktop/src/apps/SettingsApp/WifiPanel.tsx index 4175057e7..ae33153ef 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { AlertCircle, Wifi as WifiIcon } from "lucide-react"; import { Card } from "@/components/ui"; @@ -7,15 +7,14 @@ export function WifiPanel() { const [wifiEnabled, setWifiEnabled] = useState(null); const [accessPoints, setAccessPoints] = useState<{ ssid: string; strength: number; frequency: number; secured: boolean; active: boolean }[]>([]); const [error, setError] = useState(null); - const mounted = useRef(true); - useEffect(() => () => { mounted.current = false; }, []); useEffect(() => { - (async () => { + let mounted = true; + const load = async () => { try { const res = await fetch("/api/hardware/wifi", { credentials: "include" }); const body = await res.json().catch(() => null); - if (!mounted.current) return; + if (!mounted) return; if (!res.ok || body === null || typeof body.available !== "boolean") { setError("Could not load Wi-Fi state."); return; @@ -25,10 +24,17 @@ export function WifiPanel() { if (body.available && body.wifi_enabled && Array.isArray(body.access_points)) { setAccessPoints(body.access_points); } + setError(null); } catch { - if (mounted.current) setError("Could not reach taOS."); + if (mounted) setError("Could not reach taOS."); } - })(); + }; + load(); + const id = setInterval(load, 10_000); + return () => { + mounted = false; + clearInterval(id); + }; }, []); return ( From 2c4c77a671abd88c91afecb98939a9dddf8b8496 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 27 Oct 2026 01:16:34 +0000 Subject: [PATCH 2/2] address in-house review findings for tsk-br5bqw --- desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx | 2 +- desktop/src/apps/SettingsApp/BluetoothPanel.tsx | 10 +++++----- desktop/src/apps/SettingsApp/WifiPanel.test.tsx | 2 +- desktop/src/apps/SettingsApp/WifiPanel.tsx | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx index bc6aff386..bddc7b575 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; import { BluetoothPanel } from "./BluetoothPanel"; diff --git a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx index 7e2f35abb..fec553cbe 100644 --- a/desktop/src/apps/SettingsApp/BluetoothPanel.tsx +++ b/desktop/src/apps/SettingsApp/BluetoothPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Card } from "@/components/ui"; export function BluetoothPanel() { @@ -6,14 +6,14 @@ export function BluetoothPanel() { const [adapters, setAdapters] = useState<{ path: string; address: string; powered: boolean }[]>([]); const [devices, setDevices] = useState<{ path: string; address: string; name: string | null; paired: boolean; connected: boolean; trusted: boolean }[]>([]); const [error, setError] = useState(null); + const mounted = useRef(true); useEffect(() => { - let mounted = true; const load = async () => { try { const res = await fetch("/api/hardware/bluetooth", { credentials: "include" }); const body = await res.json().catch(() => null); - if (!mounted) return; + if (!mounted.current) return; if (!res.ok || body === null || typeof body.available !== "boolean") { setError("Could not load Bluetooth state."); return; @@ -27,13 +27,13 @@ export function BluetoothPanel() { } setError(null); } catch { - if (mounted) setError("Could not reach taOS."); + if (mounted.current) setError("Could not reach taOS."); } }; load(); const id = setInterval(load, 10_000); return () => { - mounted = false; + mounted.current = false; clearInterval(id); }; }, []); diff --git a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx index 2c41f23a7..9c910f0ac 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.test.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; import { WifiPanel } from "./WifiPanel"; diff --git a/desktop/src/apps/SettingsApp/WifiPanel.tsx b/desktop/src/apps/SettingsApp/WifiPanel.tsx index ae33153ef..9fc79a8f0 100644 --- a/desktop/src/apps/SettingsApp/WifiPanel.tsx +++ b/desktop/src/apps/SettingsApp/WifiPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { AlertCircle, Wifi as WifiIcon } from "lucide-react"; import { Card } from "@/components/ui"; @@ -7,14 +7,14 @@ export function WifiPanel() { const [wifiEnabled, setWifiEnabled] = useState(null); const [accessPoints, setAccessPoints] = useState<{ ssid: string; strength: number; frequency: number; secured: boolean; active: boolean }[]>([]); const [error, setError] = useState(null); + const mounted = useRef(true); useEffect(() => { - let mounted = true; const load = async () => { try { const res = await fetch("/api/hardware/wifi", { credentials: "include" }); const body = await res.json().catch(() => null); - if (!mounted) return; + if (!mounted.current) return; if (!res.ok || body === null || typeof body.available !== "boolean") { setError("Could not load Wi-Fi state."); return; @@ -26,13 +26,13 @@ export function WifiPanel() { } setError(null); } catch { - if (mounted) setError("Could not reach taOS."); + if (mounted.current) setError("Could not reach taOS."); } }; load(); const id = setInterval(load, 10_000); return () => { - mounted = false; + mounted.current = false; clearInterval(id); }; }, []);