fix(dashboard): 修首頁白屏(React #185 無限 re-render)

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-01 00:17:38 +08:00
parent 6de7c1b4a3
commit 7d8ad4857e
4 changed files with 200 additions and 6 deletions

View File

@ -0,0 +1,75 @@
/**
* ActivityTimeline
*
*
* F7/F8 WS activityIcons/activityColors map
* type lookup undefined <Icon /> render undefined crash
* fallbackCircle / 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(
<LocaleProvider>
<ActivityTimeline />
</LocaleProvider>,
);
}
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();
});
});

View File

@ -21,6 +21,7 @@ import { useEffect, useState } from "react";
import {
AlertTriangle,
CheckCircle,
Circle,
Link2,
RefreshCw,
Trash2,
@ -104,8 +105,11 @@ export function ActivityTimeline() {
) : (
<ul className="space-y-3" data-testid="activity-list">
{activities.map((activity) => {
const Icon = activityIcons[activity.type];
const color = activityColors[activity.type];
// F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
// 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色,
// 避免 <Icon /> render undefined 而 crash。
const Icon = activityIcons[activity.type] ?? Circle;
const color = activityColors[activity.type] ?? "text-muted-foreground";
return (
<li key={activity.id} className="flex items-start gap-3">
<Icon

View File

@ -0,0 +1,109 @@
/**
* ConnectedDevicesList
*
* React #185 re-render
* zustand v5 selector .filter() reference useSyncExternalStore
* Object.is false re-render filter selector
* render online/offline device render
* Maximum update depth exceededrender throw
*
* Mock
* - next/navigationjsdom app router context<Link>
*/
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>): DeviceSummary {
return {
id: "d1",
name: "Device 1",
type: "kneron",
status: "connected",
remoteStatus: "online",
lastSeenAt: null,
...over,
};
}
function renderList() {
return render(
<LocaleProvider>
<ConnectedDevicesList />
</LocaleProvider>,
);
}
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-renderstate 更新)後 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();
});
});

View File

@ -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.devicesfilter 在 component body 做。
// zustand v5 底層用 useSyncExternalStore + Object.is 比較 snapshot
// 已移除 v4 內建的 selector 淺比較;若在 selector 內 .filter() 每次回新陣列
// reference → Object.is 永遠 false → 無限 re-renderReact #185
// 對齊專案其他頁慣例devices/models 頁、activity-timelineselector 回穩定
// reference衍生計算放 render body。
const allDevices = useDeviceStore((s) => s.devices);
const devices = allDevices.filter((d) => d.remoteStatus === "online");
return (
<Card>