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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
Link2,
|
Link2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -104,8 +105,11 @@ export function ActivityTimeline() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3" data-testid="activity-list">
|
<ul className="space-y-3" data-testid="activity-list">
|
||||||
{activities.map((activity) => {
|
{activities.map((activity) => {
|
||||||
const Icon = activityIcons[activity.type];
|
// F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
|
||||||
const color = activityColors[activity.type];
|
// 涵蓋的未知 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 (
|
return (
|
||||||
<li key={activity.id} className="flex items-start gap-3">
|
<li key={activity.id} className="flex items-start gap-3">
|
||||||
<Icon
|
<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() {
|
export function ConnectedDevicesList() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接
|
// 雲端版語意:列「線上」裝置(remoteStatus=online),不是 USB 連接。
|
||||||
const devices = useDeviceStore((s) =>
|
//
|
||||||
s.devices.filter((d) => d.remoteStatus === "online"),
|
// 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user