Skip to content

Commit 9bcb472

Browse files
committed
feat(shell): add slot error boundary and loading
1 parent b567f0e commit 9bcb472

4 files changed

Lines changed: 222 additions & 28 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
* 查询的活,内联 style 表达不了);AI toast 则从导航行搬到本层的 **fixed 覆盖层**
1616
* (控制方裁决 A3:它是 1024 溢出的唯一主因,单项 373.75 px = 视口的 36.5%)。
1717
* 本文件负责装配、跨页状态与这层壳级覆盖层。
18+
* @ai-context: 批 3 T13:`PageSlot` / 两个窗口变体 / 对话面板**四处**都包了**叶级**错误边界并配
19+
* `ShellFallback` 首访加载态(此前懒 chunk 失败会一路抛到最外层 AppErrorBoundary ⇒
20+
* 整个导航壳被卸载、已访问页状态一起丢)。边界在 `shell/ShellFallback.tsx`。
1821
*/
1922
import { lazy, Suspense, useEffect, useRef, useState } from "react";
2023
import { listen } from "@tauri-apps/api/event";
@@ -36,6 +39,9 @@ import { TopBar, TopBarAction } from "./shell/TopBar";
3639
// 本批只做**页面级跳转 + 对话面板**两条动作(`focus*` 状态机原样保留,收敛属 T12)。
3740
// 自足实现、未 import 原语层(非目标 2):遮罩/Esc/焦点都在它自己文件里,批 4 换成 `Modal`。
3841
import { CommandPalette } from "./shell/CommandPalette";
42+
// 批 3 T13:壳层的**首访加载态**与**叶级错误边界**(批 2 §瓶颈清单转交的三条)——自足实现、
43+
// 刻意不 import 原语层(非目标 2,两个组件是批 4 换 `Loading` / `StatusLine` 的迁移点)。
44+
import { ShellFallback, SlotErrorBoundary } from "./shell/ShellFallback";
3945
// 批 3 T7(控制方裁决 A3):AI toast 从 56px 导航行搬到**固定覆盖层** —— 层级走六档标尺,
4046
// 不许写裸数字(`ui/zIndex.guard.test.ts` 在看着)。实测 toast 是 1024 溢出的唯一主因
4147
// (单项 373.75 px = 视口的 36.5%;含它 1375.74 px、剔除它 997.99 px),而规格 §6.1 的顶栏
@@ -96,10 +102,13 @@ function App() {
96102
// v0.12.0 M3:系统级覆盖层截图窗口入口(全屏透明 1:1 框选;无采集控制需求)
97103
if (query.get("overlay") === "1") {
98104
return (
99-
// 批 2:面板是 lazy chunk —— 首次拉取期间渲染 fallback(null),加载完即常驻(本窗无导航)
100-
<Suspense fallback={null}>
101-
<CaptureOverlayPanel />
102-
</Suspense>
105+
// 批 2:面板是 lazy chunk —— 加载完即常驻(本窗无导航)
106+
// 批 3 T13:套一层**叶级**边界 + 首访加载态 —— 此前 chunk 失败 = 本窗全空白(批 2 风险 1)
107+
<SlotErrorBoundary>
108+
<Suspense fallback={<ShellFallback />}>
109+
<CaptureOverlayPanel />
110+
</Suspense>
111+
</SlotErrorBoundary>
103112
);
104113
}
105114
// v0.12.0 M6:采集浮窗入口(独立窗口 alwaysOnTop,加载 index.html?float=1);
@@ -108,10 +117,13 @@ function App() {
108117
return (
109118
// 批 2:CaptureStatusProvider 必须留在 Suspense **外层**——它是本窗「每窗恰一个实例」的
110119
// 采集状态源(见文件头 @ai-context),塞进 Suspense 会改变它自己的挂载时机
120+
// 批 3 T13:边界加在 provider **内层**(状态源层级不动);chunk 失败不再让本窗全空白
111121
<CaptureStatusProvider>
112-
<Suspense fallback={null}>
113-
<CaptureFloatPanel />
114-
</Suspense>
122+
<SlotErrorBoundary>
123+
<Suspense fallback={<ShellFallback />}>
124+
<CaptureFloatPanel />
125+
</Suspense>
126+
</SlotErrorBoundary>
115127
</CaptureStatusProvider>
116128
);
117129
}
@@ -132,20 +144,21 @@ function App() {
132144
* @ai-context: 批 2 包体治理的挂载闸门,也是「保留挂载」语义的唯一实现点。
133145
* · mounted=false ⇒ 整棵子树不渲染 ⇒ 该页的 lazy chunk **不会被请求**(首屏收益的来源);
134146
* · mounted=true 之后永不回到 false ⇒ 已访问页面常驻(TD-004 保活语义,状态与事件监听不重置);
135-
* · 每页一个独立 Suspense(fallback=null):只有**新挂载**的页会挂起,
147+
* · 每页一个独立 Suspense + **叶级** `SlotErrorBoundary`:只有**新挂载**的页会挂起,
136148
* 已经可见的页不会因为邻居加载而被替换成 fallback(避免可见的闪烁)。
137-
* @ai-context: 为什么 fallback 是 null 而不是原语层的 Loading:本批是**尺寸治理批**,
138-
* 引入原语会把它连同 CSS 一起拉进首屏,与目标冲突;「首访加载态」登记给批 3/4
139-
* (壳层与加载原语一起做),见计划 Task 11 的瓶颈清单。
140-
* @ai-context: 动态 import 失败时 React 会把它抛到最近的错误边界 —— App.tsx 的
141-
* AppErrorBoundary 仍在最外层包着 MainShell,因此「chunk 加载失败」有兜底 UI,不会白屏。
149+
* @ai-context: 批 3 T13:fallback 从 `null` 换成 `ShellFallback`(首访加载态,静态无动效),并在
150+
* ⚠️ Suspense 之外加了**叶级**边界:懒 chunk 失败原本会一路抛到最外层 AppErrorBoundary ⇒
151+
* **整个 MainShell 被卸载**(已访问页状态一起丢,批 2 评审 M-1)。边界在本函数内 ⇒ 只卸载出错的
152+
* 那一页,兄弟槽位与壳层状态保留(证明见 `shell/ShellFallback.test.tsx` 的「叶级」用例)。
142153
* 副作用:无。边界:children 是懒组件元素,未 mounted 时不会被 React 渲染 ⇒ 不触发 dynamic import。
143154
*/
144155
function PageSlot({ show, mounted, children }: { show: boolean; mounted: boolean; children: React.ReactNode }) {
145156
if (!mounted) return null;
146157
return (
147158
<div style={{ flex: 1, display: show ? "block" : "none", overflow: "hidden" }}>
148-
<Suspense fallback={null}>{children}</Suspense>
159+
<SlotErrorBoundary>
160+
<Suspense fallback={<ShellFallback />}>{children}</Suspense>
161+
</SlotErrorBoundary>
149162
</div>
150163
);
151164
}
@@ -537,20 +550,22 @@ function MainShell() {
537550
/>
538551
{/* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活)。
539552
批 2:闸门只加在**首开之前** —— dockMounted 一旦为真永不复位,此后 open/close
540-
仍是纯 display 切换(保活语义逐字保留);独立 Suspense(fallback=null) 与 PageSlot
541-
同理由:chunk 首次到达前不渲染任何东西,加载失败由外层 AppErrorBoundary 兜底。 */}
553+
仍是纯 display 切换(保活语义逐字保留);独立 Suspense 与 PageSlot 同理由:chunk 首次
554+
到达前渲染 `ShellFallback`(批 3 T13),加载失败由**这一层的叶级边界**兜底(不再拖垮整壳)。 */}
542555
{dockMounted && (
543-
<Suspense fallback={null}>
544-
<AiConversationDock
545-
open={dockOpen}
546-
onClose={() => setDockOpen(false)}
547-
onOpenChat={goChatSession}
548-
onOpenTaskInChat={goChatTask}
549-
onOpenSessions={goSessions}
550-
onOpenNote={openNotePlain}
551-
onOpenRefineWorkbench={goRefineWorkbench}
552-
/>
553-
</Suspense>
556+
<SlotErrorBoundary>
557+
<Suspense fallback={<ShellFallback />}>
558+
<AiConversationDock
559+
open={dockOpen}
560+
onClose={() => setDockOpen(false)}
561+
onOpenChat={goChatSession}
562+
onOpenTaskInChat={goChatTask}
563+
onOpenSessions={goSessions}
564+
onOpenNote={openNotePlain}
565+
onOpenRefineWorkbench={goRefineWorkbench}
566+
/>
567+
</Suspense>
568+
</SlotErrorBoundary>
554569
)}
555570
</div>
556571
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context 壳层失败/等待态的守卫(批 3 Task 13;批 2 §瓶颈清单转交的三条)。
4+
* 两把**独立**的尺子(各自能单独变红):
5+
* ① **渲染尺**(真组件 + 真抛错):`SlotErrorBoundary` 的**叶级**语义 —— 出错只卸载那一棵子树;
6+
* 边界外的壳层(持 state 的父组件 = `MainShell` 的等价物)与**兄弟槽位**仍在,壳层 state 逐字
7+
* 保活(先点两次计数器再让子页抛错,随后断言计数仍是 2 —— 这是证明,不是声明)。
8+
* ② **结构尺**(真 `App.tsx` 源码):**按槽位逐个判** —— 9 个 `PageSlot` 调用点与注册表 9 个 key
9+
* 一一对应,且每个调用点都经过那唯一一个内部包了边界的 `PageSlot` 定义;三个非槽位 Suspense
10+
* (overlay / float / dock)各有边界。⚠️ Why 不数全文出现次数:import 行 / 注释 / 类型标注都会计入
11+
* (计划 V3 的 `boundaries≥4` 因此偏松),且那条正则**对空格敏感**(`fallback={ null }` 漏判)。
12+
*
13+
* ⚠️ 诚实边界:本仓**没有** `App.test.tsx`(批 2 实测)⇒ `App.tsx` 的接线**无渲染级测试面**;① 测的是
14+
* 与 App **同构**的复刻(② 证明两者同形),真产物像素证据归 T14。
15+
*/
16+
import { readFileSync } from "node:fs";
17+
import { dirname, join } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
import { useState } from "react";
20+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
21+
import { afterEach, describe, expect, it, vi } from "vitest";
22+
import { ShellFallback, SlotErrorBoundary } from "./ShellFallback";
23+
import { ALL_ENTRIES } from "./navRegistry";
24+
25+
/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`;与 `TopBar.test.tsx:43` 同一口径) */
26+
const APP = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "App.tsx"), "utf8")
27+
.replace(/\r\n/g, "\n").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
28+
/** 模拟懒 chunk 加载失败(渲染期抛错 ⇒ React 落到最近的上层边界);抛错时 React 仍会 console.error ⇒ 抑制后还原 */
29+
function Boom(): never { throw new Error("模拟懒 chunk 加载失败"); }
30+
function silenced(fn: () => void) {
31+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
32+
try { fn(); } finally { spy.mockRestore(); }
33+
}
34+
afterEach(cleanup);
35+
36+
describe("首访加载态 ShellFallback(批 2 未做 #6)", () => {
37+
it("渲染一行文字且是 role=status;内联样式里没有动效(动效属批 6)", () => {
38+
render(<ShellFallback />);
39+
const el = screen.getByRole("status");
40+
expect(el.textContent).toBe("正在载入…");
41+
expect(el.getAttribute("data-testid")).toBe("shell-fallback");
42+
expect(el.getAttribute("style") ?? "").not.toMatch(/transition|animation/);
43+
expect(screen.queryByRole("alert"), "加载态不是错误态").toBeNull();
44+
});
45+
});
46+
47+
describe("叶级错误边界 SlotErrorBoundary", () => {
48+
it("① 子组件抛错 ⇒ 只渲染失败卡片,children 不再挂载", () => {
49+
function Wrap({ bad }: { bad: boolean }) {
50+
return <SlotErrorBoundary><span data-testid="child-marker">子内容</span>{bad && <Boom />}</SlotErrorBoundary>;
51+
}
52+
const { rerender } = render(<Wrap bad={false} />);
53+
expect(screen.getByTestId("child-marker"), "阳性对照:正常态本来该挂载").toBeTruthy();
54+
silenced(() => rerender(<Wrap bad />));
55+
expect(screen.getByRole("alert").textContent).toContain("加载失败");
56+
expect(screen.queryByTestId("child-marker"), "children 仍挂着 ⇒ 没被边界卸载").toBeNull();
57+
expect(screen.queryByTestId("shell-fallback"), "失败态误用了加载态").toBeNull();
58+
});
59+
60+
it("② 叶级:出错只卸载那一页 —— 壳层与兄弟槽位还在、壳层 state 保活", () => {
61+
function Shell() {
62+
const [page, setPage] = useState("classroom");
63+
const [alive, setAlive] = useState(0);
64+
return (
65+
<div data-testid="main-shell">
66+
<button data-testid="bump" onClick={() => setAlive((n) => n + 1)}>计数</button>
67+
<button data-testid="fail" onClick={() => setPage("broken")}>切到坏页</button>
68+
<span data-testid="alive">{alive}</span>
69+
<SlotErrorBoundary>{page === "broken" ? <Boom /> : <span data-testid="page-a">课堂页</span>}</SlotErrorBoundary>
70+
<SlotErrorBoundary><span data-testid="page-b">会话页</span></SlotErrorBoundary>
71+
</div>
72+
);
73+
}
74+
render(<Shell />);
75+
expect(screen.getByTestId("page-a"), "阳性对照:坏页之前本来是好的").toBeTruthy();
76+
fireEvent.click(screen.getByTestId("bump"));
77+
fireEvent.click(screen.getByTestId("bump"));
78+
expect(screen.getByTestId("alive").textContent).toBe("2");
79+
silenced(() => fireEvent.click(screen.getByTestId("fail")));
80+
expect(screen.getAllByTestId("slot-error"), "只该有一个槽位变失败卡").toHaveLength(1);
81+
expect(screen.getByTestId("main-shell"), "MainShell 被一起卸载了").toBeTruthy();
82+
expect(screen.getByTestId("alive").textContent, "壳层 state 丢了 ⇒ 不是叶级边界").toBe("2");
83+
expect(screen.getByTestId("page-b"), "兄弟槽位被带走了").toBeTruthy();
84+
expect(screen.queryByTestId("page-a"), "出错的页没被卸载").toBeNull();
85+
});
86+
});
87+
88+
describe("结构尺:App.tsx 的边界**按槽位逐个判**(不用全文计数)", () => {
89+
const slots = [...APP.matchAll(/<PageSlot\b[^>]*>/g)].map((m) => m[0]);
90+
91+
it("① 9 个 key ↔ 9 个 PageSlot 调用点一一对应(show / mounted 同键)", () => {
92+
expect(slots, "PageSlot 调用点数 ≠ 注册表页数").toHaveLength(ALL_ENTRIES.length);
93+
for (const e of ALL_ENTRIES) {
94+
const hit = slots.filter((s) => s.includes(`mounted={mountedPages.has("${e.key}")}`));
95+
expect(hit, `${e.key} 没有唯一的槽位`).toHaveLength(1);
96+
expect(hit[0], `${e.key} 的 show 与 mounted 键不一致`).toContain(`show={page === "${e.key}"}`);
97+
}
98+
});
99+
100+
it("② 页级定义包住 children;overlay / float / dock 三处各一个边界", () => {
101+
const at = (s: string) => APP.indexOf(s);
102+
const ps = at("function PageSlot(");
103+
const def = APP.slice(ps, APP.indexOf("\n}\n", ps));
104+
const overlay = APP.slice(at('query.get("overlay") === "1"'), at('query.get("float") === "1"'));
105+
const float = APP.slice(at('query.get("float") === "1"'), at("<AppErrorBoundary>"));
106+
const dock = APP.slice(at("{dockMounted && ("), at("export default"));
107+
expect(def, "页级定义没把边界包在 children 之外(M-1 的缺陷形态)").toMatch(
108+
/<SlotErrorBoundary>\s*<Suspense fallback=\{<ShellFallback \/>\}>\{children\}<\/Suspense>\s*<\/SlotErrorBoundary>/,
109+
);
110+
for (const [name, seg] of [["overlay", overlay], ["float", float], ["dock", dock]] as const) {
111+
expect(seg.length, `${name} 切片为空(定位器写错?)`).toBeGreaterThan(40);
112+
expect(seg, `${name} 分支缺叶级边界`).toContain("<SlotErrorBoundary>");
113+
expect(seg, `${name} 分支缺首访加载态`).toContain("fallback={<ShellFallback />}");
114+
}
115+
// float:CaptureStatusProvider 必须仍在边界**外层**(「每窗恰一个实例」的采集状态源)
116+
expect(float.indexOf("<CaptureStatusProvider>"), "采集状态源被塞进边界内层了").toBeLessThan(float.indexOf("<SlotErrorBoundary>"));
117+
expect(APP, "仍有 fallback={null} 残留").not.toMatch(/fallback=\{\s*null\s*\}/);
118+
});
119+
});

‎app/src/shell/ShellFallback.tsx‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @ai-context 壳层的两个失败/等待态(批 2 §瓶颈清单转交批 3 的三条:Task 6 评审 M-1 · Task 7 风险 1 · 未做 #6)。
3+
*
4+
* Why 自足内联、**不** import L1 原语层(`Loading` / `ErrorState`):批 3 非目标 2 明令不许 import
5+
* 原语层 —— 那会把 `motion.css` 与整层 CSS 拉回首屏,吃掉批 2 挣来的余量。
6+
* ⇒ 🔴 **本文件是批 4 的迁移点**:`ShellFallback` → `Loading`、失败卡片 → `StatusLine`(批 4 计划
7+
* 必须收编这两个组件,否则永远漏在这里)。
8+
*
9+
* 覆盖的三条:
10+
* ① `SlotErrorBoundary` 是**叶级**错误边界:它包**一页 / 一个面板**,不是整壳。此前 `PageSlot`
11+
* 之上只有全局 `AppErrorBoundary` ⇒ 懒 chunk 加载失败会卸载**整个 MainShell**
12+
* (已访问页状态、`mountedPages` 保活集合一起丢)。兄弟槽位与壳层状态在本边界之外。
13+
* ② 两个窗口变体(`?float=1` / `?overlay=1`)此前无边界 ⇒ chunk 失败 = 全窗口空白。
14+
* ③ 首访加载态:`Suspense fallback={null}` ⇒ 改用 `ShellFallback`(**静态**、无动效 —— 动效属批 6)。
15+
*
16+
* 副作用:无(`SlotErrorBoundary` 只读 `getDerivedStateFromError`;不发日志、不上报 —— 本地优先)。
17+
* 边界:**不做重试按钮**(重试语义要与 Tauri 的 chunk 缓存一起设计,登记给批 4/8);失败卡片
18+
* 只给静态文案,不回显 error 细节(细节仍由最外层 `AppErrorBoundary` 承担)。
19+
*/
20+
import { Component, type ReactNode } from "react";
21+
22+
/** 首访加载态:懒 chunk 到达前的一行静态占位(无动效、无图标 —— 批 3 非目标 4)。 */
23+
export function ShellFallback() {
24+
return (
25+
<div
26+
role="status"
27+
data-testid="shell-fallback"
28+
style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80, padding: 24, fontSize: 12, color: "#6b7280" }}
29+
>
30+
正在载入…
31+
</div>
32+
);
33+
}
34+
35+
/**
36+
* 叶级错误边界:只卸载出错的那一棵子树(一页 / 一个面板),兄弟槽位与壳层状态保留。
37+
* @ai-context 状态只有 `failed` 一个布尔位,且**不提供复位入口**(见文件头「边界」)。
38+
*/
39+
export class SlotErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
40+
state: { failed: boolean } = { failed: false };
41+
42+
static getDerivedStateFromError() {
43+
return { failed: true };
44+
}
45+
46+
render() {
47+
if (this.state.failed) {
48+
return (
49+
<div
50+
role="alert"
51+
data-testid="slot-error"
52+
style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80, padding: 24, fontSize: 12, color: "#b91c1c" }}
53+
>
54+
此处加载失败——可切换页面继续使用;重启应用可恢复。
55+
</div>
56+
);
57+
}
58+
return this.props.children;
59+
}
60+
}

0 commit comments

Comments
 (0)