From 7d8ad4857eb98dd1477ae5538a2e827ca4b0e58f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 1 Jul 2026 00:17:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20=E4=BF=AE=E9=A6=96=E9=A0=81?= =?UTF-8?q?=E7=99=BD=E5=B1=8F=EF=BC=88React=20#185=20=E7=84=A1=E9=99=90=20?= =?UTF-8?q?re-render=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connected-devices-list 的 selector 內 .filter() 每次回新陣列,zustand v5 移除淺比較後 Object.is 永 false → 無限 re-render → #185 整頁白屏(配對後 有 device 資料才觸發)。 - selector 改回只取 s.devices(穩定 ref),filter 移到 component body - activity-timeline 未知 type 加 fallback(?? Circle / muted)防 F7/F8 WS 事件 crash - 測試 render not.toThrow + act flush 驗不再迴圈 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/activity-timeline.test.tsx | 75 ++++++++++++ .../dashboard/activity-timeline.tsx | 8 +- .../dashboard/connected-devices-list.test.tsx | 109 ++++++++++++++++++ .../dashboard/connected-devices-list.tsx | 14 ++- 4 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 visionA-frontend/src/components/dashboard/activity-timeline.test.tsx create mode 100644 visionA-frontend/src/components/dashboard/connected-devices-list.test.tsx diff --git a/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx b/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx new file mode 100644 index 0000000..53a527c --- /dev/null +++ b/visionA-frontend/src/components/dashboard/activity-timeline.test.tsx @@ -0,0 +1,75 @@ +/** + * ActivityTimeline 測試 + * + * 回歸重點: + * F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors map 尚未涵蓋的 + * 未知 type → lookup 回 undefined → render undefined 會 crash。修法加 + * fallback(Circle / text-muted-foreground)。這裡塞一筆未知 type 的 activity, + * 確認 render 不 throw。 + * + * 另驗:已知 type 正常渲染、空狀態。 + */ + +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { + useActivityStore, + type ActivityEntry, + type ActivityType, +} from "@/stores/activity-store"; + +import { ActivityTimeline } from "./activity-timeline"; + +function renderTimeline() { + return render( + + + , + ); +} + +beforeEach(() => { + useActivityStore.setState({ activities: [] }); +}); + +afterEach(() => { + useActivityStore.setState({ activities: [] }); +}); + +describe("ActivityTimeline", () => { + it("空狀態:顯示空文案", () => { + expect(() => renderTimeline()).not.toThrow(); + expect(screen.getByText(/還沒有任何活動/)).toBeInTheDocument(); + }); + + it("已知 type 正常渲染", () => { + const entry: ActivityEntry = { + id: "a1", + type: "device_paired", + message: "配對成功", + timestamp: Date.now(), + }; + useActivityStore.setState({ activities: [entry] }); + + expect(() => renderTimeline()).not.toThrow(); + expect(screen.getByText("配對成功")).toBeInTheDocument(); + }); + + it("未知 type(後端推未涵蓋事件)→ fallback icon/色,不 crash", () => { + // 模擬 F7/F8 接 WS 後推入 map 尚未涵蓋的 type。 + const entry: ActivityEntry = { + id: "a-unknown", + type: "some_future_event" as ActivityType, + message: "未知事件", + timestamp: Date.now(), + }; + useActivityStore.setState({ activities: [entry] }); + + expect(() => renderTimeline()).not.toThrow(); + expect(screen.getByText("未知事件")).toBeInTheDocument(); + // 列表有渲染(fallback icon 沒讓整列消失) + expect(screen.getByTestId("activity-list")).toBeInTheDocument(); + }); +}); diff --git a/visionA-frontend/src/components/dashboard/activity-timeline.tsx b/visionA-frontend/src/components/dashboard/activity-timeline.tsx index 9ef7e95..35e9bc8 100644 --- a/visionA-frontend/src/components/dashboard/activity-timeline.tsx +++ b/visionA-frontend/src/components/dashboard/activity-timeline.tsx @@ -21,6 +21,7 @@ import { useEffect, useState } from "react"; import { AlertTriangle, CheckCircle, + Circle, Link2, RefreshCw, Trash2, @@ -104,8 +105,11 @@ export function ActivityTimeline() { ) : (
    {activities.map((activity) => { - const Icon = activityIcons[activity.type]; - const color = activityColors[activity.type]; + // F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未 + // 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色, + // 避免 render undefined 而 crash。 + const Icon = activityIcons[activity.type] ?? Circle; + const color = activityColors[activity.type] ?? "text-muted-foreground"; return (
  • 內部需要) + */ + +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { LocaleProvider } from "@/lib/i18n/context"; +import { useDeviceStore, type DeviceSummary } from "@/stores/device-store"; + +import { ConnectedDevicesList } from "./connected-devices-list"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(), + }), +})); + +function makeDevice(over: Partial): DeviceSummary { + return { + id: "d1", + name: "Device 1", + type: "kneron", + status: "connected", + remoteStatus: "online", + lastSeenAt: null, + ...over, + }; +} + +function renderList() { + return render( + + + , + ); +} + +beforeEach(() => { + useDeviceStore.setState({ devices: [] }); +}); + +afterEach(() => { + useDeviceStore.setState({ devices: [] }); +}); + +describe("ConnectedDevicesList", () => { + it("無 online 裝置時顯示空狀態(不無限迴圈)", () => { + useDeviceStore.getState()._setDevices([ + makeDevice({ id: "off1", remoteStatus: "offline" }), + ]); + // 若 selector 迴圈仍在,render 會 throw "Maximum update depth exceeded"。 + expect(() => renderList()).not.toThrow(); + // 沒有 online 裝置 → 不渲染列表,顯示空狀態文案 + expect(screen.queryByTestId("connected-devices-list")).not.toBeInTheDocument(); + expect( + screen.getByText(/目前沒有裝置線上/), + ).toBeInTheDocument(); + }); + + it("混合 online/offline 裝置:只列 online,且不無限迴圈", () => { + useDeviceStore.getState()._setDevices([ + makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }), + makeDevice({ id: "off1", name: "Offline B", remoteStatus: "offline" }), + makeDevice({ id: "on2", name: "Online C", remoteStatus: "online" }), + ]); + expect(() => renderList()).not.toThrow(); + + const list = screen.getByTestId("connected-devices-list"); + expect(list).toBeInTheDocument(); + expect(screen.getByText("Online A")).toBeInTheDocument(); + expect(screen.getByText("Online C")).toBeInTheDocument(); + expect(screen.queryByText("Offline B")).not.toBeInTheDocument(); + }); + + it("re-render(state 更新)後 selector 穩定、不爆迴圈", () => { + useDeviceStore.getState()._setDevices([ + makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }), + ]); + renderList(); + expect(screen.getByText("Online A")).toBeInTheDocument(); + + // 再次更新 store(模擬裝置狀態變化)→ 不應觸發無限迴圈。 + // 外部 store 更新需用 act 包覆讓 React flush re-render。 + expect(() => { + act(() => { + useDeviceStore.getState()._setDevices([ + makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }), + makeDevice({ id: "on2", name: "Online D", remoteStatus: "online" }), + ]); + }); + }).not.toThrow(); + expect(screen.getByText("Online D")).toBeInTheDocument(); + }); +}); diff --git a/visionA-frontend/src/components/dashboard/connected-devices-list.tsx b/visionA-frontend/src/components/dashboard/connected-devices-list.tsx index 7748fbb..a7984fc 100644 --- a/visionA-frontend/src/components/dashboard/connected-devices-list.tsx +++ b/visionA-frontend/src/components/dashboard/connected-devices-list.tsx @@ -26,10 +26,16 @@ import { useDeviceStore } from "@/stores/device-store"; export function ConnectedDevicesList() { const t = useT(); - // 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接 - const devices = useDeviceStore((s) => - s.devices.filter((d) => d.remoteStatus === "online"), - ); + // 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接。 + // + // selector 只取穩定 reference 的 s.devices,filter 在 component body 做。 + // zustand v5 底層用 useSyncExternalStore + Object.is 比較 snapshot, + // 已移除 v4 內建的 selector 淺比較;若在 selector 內 .filter() 每次回新陣列 + // reference → Object.is 永遠 false → 無限 re-render(React #185)。 + // 對齊專案其他頁慣例(devices/models 頁、activity-timeline):selector 回穩定 + // reference,衍生計算放 render body。 + const allDevices = useDeviceStore((s) => s.devices); + const devices = allDevices.filter((d) => d.remoteStatus === "online"); return (