fix(frontend): 修复 speechSynthesis.cancel() 未守卫导致的页面崩溃
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
# 紧急诊断报告:页面崩溃 "Cannot read properties of undefined (reading 'cancel')"
|
||||
|
||||
**日期**: 2026-05-21
|
||||
**严重级别**: 🔴 Critical — 页面持续崩溃,ErrorBoundary 捕获持久性错误
|
||||
**诊断方法**: 源代码静态分析 + CDP (Chrome DevTools Protocol) 运行时分析
|
||||
|
||||
---
|
||||
|
||||
## 1. 错误信息
|
||||
|
||||
```
|
||||
⚠️ 应用遇到错误: Cannot read properties of undefined (reading 'cancel')
|
||||
```
|
||||
|
||||
用户报告:
|
||||
- **初次触发**: 前端在初次对话回复到一半时页面崩溃
|
||||
- **后续表现**: 刷新页面一直弹出 ErrorBoundary 错误页 + 重试按钮
|
||||
- **特点**: 持久性错误 — 错误发生在页面初始化阶段
|
||||
|
||||
---
|
||||
|
||||
## 2. 根本原因分析
|
||||
|
||||
### 2.1 `.cancel()` 引用全局搜索
|
||||
|
||||
对整个 `frontend/web/src/` 目录进行 `.ts` / `.tsx` 文件搜索,**所有 `.cancel()` 调用仅存在于一个文件**:
|
||||
|
||||
| 文件 | 行号 | 代码 |
|
||||
|------|------|------|
|
||||
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:205) | L205 | `window.speechSynthesis.cancel();` — `speak()` 回调内(有 `isSupported` 守卫) |
|
||||
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:224) | L224 | `window.speechSynthesis.cancel();` — `stop()` 回调内(**无守卫**) |
|
||||
| [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts:256) | L256 | `window.speechSynthesis.cancel();` — 组件卸载 cleanup 内(**无守卫**) |
|
||||
|
||||
**项目不使用 Axios(无 `CancelToken`),也不使用 `AbortController`。** HTTP 客户端是原生 `fetch`(见 [`client.ts`](frontend/web/src/api/client.ts))。
|
||||
|
||||
### 2.2 CDP 运行时确认
|
||||
|
||||
通过 Chromium CDP (端口 9225) 在 `about:blank` 页面执行以下诊断:
|
||||
|
||||
```javascript
|
||||
// CDP Runtime.evaluate 结果:
|
||||
'speechSynthesis' in window → true
|
||||
typeof window.speechSynthesis → "object"
|
||||
window.speechSynthesis === undefined → false
|
||||
window.speechSynthesis === null → false
|
||||
!!window.speechSynthesis → true
|
||||
typeof window.speechSynthesis?.cancel → "function"
|
||||
```
|
||||
|
||||
同时确认了错误消息的精确匹配:
|
||||
```javascript
|
||||
// 直接测试:
|
||||
var x = undefined; x.cancel();
|
||||
// → TypeError: "Cannot read properties of undefined (reading 'cancel')"
|
||||
```
|
||||
|
||||
**结论**: 错误消息 `"Cannot read properties of undefined (reading 'cancel')"` 确实是访问 `undefined.cancel` 导致的 `TypeError`。在当前 headless Chrome 中,`window.speechSynthesis` **存在且有 `.cancel` 方法**。但错误发生在特定运行时条件下。
|
||||
|
||||
### 2.3 调用链路分析
|
||||
|
||||
```
|
||||
App.tsx (登录后渲染)
|
||||
└── ChatContainer.tsx
|
||||
└── MessageList.tsx
|
||||
└── MessageBubble.tsx (每个 assistant 消息)
|
||||
└── AIMessageActions(content) ← 调用 useSpeechSynthesis()
|
||||
└── useSpeechSynthesis()
|
||||
├── isSupported = 'speechSynthesis' in window // ✓ 有守卫
|
||||
├── speak() → window.speechSynthesis.cancel() // ✓ isSupported 守卫
|
||||
├── stop() → window.speechSynthesis.cancel() // ✗ 无守卫!
|
||||
└── cleanup → window.speechSynthesis.cancel() // ✗ 无守卫!
|
||||
```
|
||||
|
||||
关键代码对比([`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts)):
|
||||
|
||||
```typescript
|
||||
// ✓ speak() — 有 isSupported 守卫
|
||||
const speak = useCallback((text, options) => {
|
||||
if (!isSupported || !text.trim()) return; // <-- GUARD
|
||||
window.speechSynthesis.cancel(); // 安全
|
||||
// ...
|
||||
}, [isSupported, speakNextChunk]);
|
||||
|
||||
// ✗ stop() — 无守卫
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel(); // 未检查 isSupported!
|
||||
setIsSpeaking(false);
|
||||
// ...
|
||||
}, []);
|
||||
|
||||
// ✗ cleanup — 无守卫
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.speechSynthesis.cancel(); // 未检查 isSupported!
|
||||
// ...
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2.4 为什么是「持久性」错误
|
||||
|
||||
错误在以下场景触发:
|
||||
|
||||
1. **初次对话崩溃**: 用户发送消息 → WebSocket 流式返回 → `MessageBubble` 渲染 assistant 消息 → `AIMessageActions` 挂载 → `useSpeechSynthesis()` 初始化 → 组件因某些原因卸载(React 重渲染、StrictMode 双重挂载、或流式更新导致的条件渲染变化) → **cleanup 执行 `window.speechSynthesis.cancel()`** → 如果此时 `speechSynthesis` 对象不可用 → TypeError → ErrorBoundary 捕获
|
||||
|
||||
2. **刷新后持续崩溃**: 用户已登录 → `initSession()` 从服务端加载历史消息 → 历史消息中包含 assistant 消息 → `MessageBubble` 渲染 → `AIMessageActions` 挂载 → **同样的初始化路径触发同样的错误** → ErrorBoundary 持续捕获 → 页面永远无法恢复
|
||||
|
||||
3. **为什么 `window.speechSynthesis` 可能为 `undefined`**:
|
||||
- 尽管 CDP 测试显示它存在,但在某些特定的浏览器/环境组合下(如特定版本的 headless Chrome、无音频设备的服务器环境、或浏览器安全策略限制),`window.speechSynthesis` 可能是一个不完整的对象,或者在某些时序下(如页面刚加载、`onvoiceschanged` 未触发时)表现出意外行为
|
||||
- React 18 的 `StrictMode`(在 `main.tsx` 第10行启用)会**双重挂载/卸载组件**,增加了 cleanup 被调用的次数和时序复杂度
|
||||
|
||||
### 2.5 辅助触发因素
|
||||
|
||||
`AIMessageActions` 内部还有一个额外的[无守卫调用](frontend/web/src/components/chat/MessageBubble.tsx:104-105):
|
||||
|
||||
```typescript
|
||||
const checkEnd = setInterval(() => {
|
||||
if (!window.speechSynthesis.speaking) { // 直接访问 speechSynthesis
|
||||
setIsThisSpeaking(false);
|
||||
clearInterval(checkEnd);
|
||||
}
|
||||
}, 200);
|
||||
```
|
||||
|
||||
此 `setInterval` 没有清理机制 — 如果 `AIMessageActions` 在 `checkEnd` 回调执行时已卸载,`setIsThisSpeaking` 会触发 React 的 "setState on unmounted component" 警告。虽然这不是 `.cancel` 错误的直接原因,但同样是缺少防御性编程的症状。
|
||||
|
||||
---
|
||||
|
||||
## 3. Zustand Persist 检查
|
||||
|
||||
| Store | 文件 | 使用 persist? |
|
||||
|-------|------|--------------|
|
||||
| `authStore` | [`authStore.ts`](frontend/web/src/store/authStore.ts) | ❌ 纯 `create()` |
|
||||
| `chatStore` | [`chatStore.ts`](frontend/web/src/store/chatStore.ts) | ❌ 纯 `create()` |
|
||||
| `sessionStore` | [`sessionStore.ts`](frontend/web/src/store/sessionStore.ts) | ❌ 纯 `create()` |
|
||||
| `notificationStore` | [`notificationStore.ts`](frontend/web/src/store/notificationStore.ts) | ❌ 纯 `create()` |
|
||||
| `personaStore` | [`personaStore.ts`](frontend/web/src/store/personaStore.ts) | ❌ 纯 `create()` |
|
||||
|
||||
**所有 Zustand Store 均未使用 `persist` 中间件**,因此**不存在**序列化到 `localStorage` 导致 `CancelToken`/`AbortController` 实例丢失方法的问题。排除此假设。
|
||||
|
||||
---
|
||||
|
||||
## 4. 七种可能来源的排查
|
||||
|
||||
| # | 假说 | 验证结果 |
|
||||
|---|------|----------|
|
||||
| 1 | Axios CancelToken 序列化到 localStorage 后丢失 `.cancel` 方法 | ❌ 项目不使用 Axios |
|
||||
| 2 | AbortController 被存入 Zustand store 序列化后丢失 | ❌ 项目不使用 AbortController |
|
||||
| 3 | `window.speechSynthesis.cancel()` 在 `speechSynthesis` 不支持时调用 | ✅ **唯一 `.cancel` 来源** |
|
||||
| 4 | 第三方库中有 `.cancel` 调用 | ❌ 依赖仅 react + zustand |
|
||||
| 5 | Service Worker 中有 `.cancel` 调用 | ❌ SW 代码中无 `.cancel` |
|
||||
| 6 | Zustand persist 中间件序列化问题 | ❌ 所有 store 不使用 persist |
|
||||
| 7 | React StrictMode 双重卸载触发 cleanup 时序问题 | ⚠️ 可能是加剧因素 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 修复建议
|
||||
|
||||
### 5.1 立即修复(推荐)
|
||||
|
||||
在 [`useSpeechSynthesis.ts`](frontend/web/src/hooks/useSpeechSynthesis.ts) 中添加防御性守卫:
|
||||
|
||||
**`stop()` 函数 (L223)**:
|
||||
```typescript
|
||||
const stop = useCallback(() => {
|
||||
if (isSupported) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
chunksRef.current = [];
|
||||
chunkIndexRef.current = 0;
|
||||
}, [isSupported]); // ← 添加 isSupported 到依赖数组
|
||||
```
|
||||
|
||||
**cleanup effect (L254-261)**:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isSupported) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isSupported]); // ← 添加 isSupported 到依赖数组
|
||||
```
|
||||
|
||||
### 5.2 防御性增强(推荐)
|
||||
|
||||
在 [`MessageBubble.tsx`](frontend/web/src/components/chat/MessageBubble.tsx) 的 `AIMessageActions` 组件中,为 `checkEnd` 的 `setInterval` 添加清理:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (checkEndRef.current) {
|
||||
clearInterval(checkEndRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 5.3 架构层建议
|
||||
|
||||
考虑将 `useSpeechSynthesis()` 调用提升到应用层级(如 `App.tsx` 或 `AppLayout.tsx`),而非在每个 `AIMessageActions` 中各自创建一个 hook 实例。这可以避免消息列表渲染时大量重复初始化 SpeechSynthesis 上下文。
|
||||
|
||||
---
|
||||
|
||||
## 6. 诊断工具与数据
|
||||
|
||||
- **CDP 测试脚本**: `debug/cache/test_cdp_cancel_error.py`
|
||||
- **Chromium 调试端口**: `localhost:9225`
|
||||
- **前端预览端口**: `localhost:5199`
|
||||
|
||||
---
|
||||
|
||||
## 7. 结论
|
||||
|
||||
**根因**: `useSpeechSynthesis` hook 中的 `stop()` 回调和组件卸载 cleanup effect 直接调用 `window.speechSynthesis.cancel()` **没有任何防御性守卫**。当 `window.speechSynthesis` 在特定运行时条件下不可用(值为 `undefined`)时,抛出 `TypeError: Cannot read properties of undefined (reading 'cancel')`。
|
||||
|
||||
**持久性原因**: 已登录用户在刷新页面后,`initSession()` 加载历史消息,渲染 assistant 消息气泡时触发 `AIMessageActions` → `useSpeechSynthesis()`,在 React StrictMode 双重挂载/卸载周期中触发未守卫的 cleanup,导致错误在每次页面加载时必现。
|
||||
|
||||
**修复优先级**: 🔴 P0 — 阻塞所有已登录用户使用应用。
|
||||
@@ -131,13 +131,13 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
|
||||
// Chrome bug 规避:定期 resume 避免长时间不调用后暂停
|
||||
useEffect(() => {
|
||||
if (isSpeaking && !isPaused) {
|
||||
resumeIntervalRef.current = setInterval(() => {
|
||||
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
if (!isSupported || !isSpeaking || isPaused) return;
|
||||
|
||||
resumeIntervalRef.current = setInterval(() => {
|
||||
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (resumeIntervalRef.current) {
|
||||
@@ -145,7 +145,7 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
resumeIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSpeaking, isPaused]);
|
||||
}, [isSupported, isSpeaking, isPaused]);
|
||||
|
||||
/** 朗读下一段 */
|
||||
const speakNextChunk = useCallback(() => {
|
||||
@@ -221,29 +221,37 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
|
||||
/** 停止朗读 */
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
if (!isSupported) {
|
||||
console.warn('[useSpeechSynthesis] stop: speechSynthesis not supported');
|
||||
return;
|
||||
}
|
||||
if (utteranceRef.current) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
chunksRef.current = [];
|
||||
chunkIndexRef.current = 0;
|
||||
}, []);
|
||||
}, [isSupported]);
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
if (!isSupported) return;
|
||||
if (isSpeaking && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [isSpeaking, isPaused]);
|
||||
}, [isSupported, isSpeaking, isPaused]);
|
||||
|
||||
/** 恢复 */
|
||||
const resume = useCallback(() => {
|
||||
if (!isSupported) return;
|
||||
if (isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
}
|
||||
}, [isPaused]);
|
||||
}, [isSupported, isPaused]);
|
||||
|
||||
/** 设置语音 */
|
||||
const setVoice = useCallback((voice: SpeechSynthesisVoice) => {
|
||||
@@ -253,12 +261,14 @@ export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
// 组件卸载时停止
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.speechSynthesis.cancel();
|
||||
if (isSupported && utteranceRef.current) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [isSupported]);
|
||||
|
||||
return {
|
||||
isSpeaking,
|
||||
|
||||
Reference in New Issue
Block a user