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:
parent
6de7c1b4a3
commit
7d8ad4857e
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* ActivityTimeline 測試
|
||||
*
|
||||
* 回歸重點:
|
||||
* F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors map 尚未涵蓋的
|
||||
* 未知 type → lookup 回 undefined → <Icon /> 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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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 exceeded」(若迴圈仍在,render 會 throw)。
|
||||
*
|
||||
* Mock:
|
||||
* - next/navigation(jsdom 無 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-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();
|
||||
});
|
||||
});
|
||||
@ -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 (
|
||||
<Card>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user