2026-04-16-20-24-11 - 从 Ref 读取修复 saveDraftToStorage 闭包陷阱和 DOM 失效导致的内容丢失

This commit is contained in:
2026-04-16 20:29:06 +08:00
parent 39ecdf2b71
commit 85080e5630
5 changed files with 259 additions and 7 deletions

View File

@@ -85,17 +85,18 @@ export default function ReportEditor() {
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : ''; const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) { if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, { storage.set(key, {
content: editorRef.current?.innerHTML || '', content: currentContent,
draftReportId: reportId || null, draftReportId: reportId || null,
reportData, reportData: stateRef.current.reportData,
videos, videos: stateRef.current.videos,
capturedFrames, capturedFrames: stateRef.current.capturedFrames,
activeTab, activeTab: stateRef.current.activeTab,
loadedTemplateId loadedTemplateId: stateRef.current.loadedTemplateId
}); });
} }
}, [reportData, videos, capturedFrames, activeTab, loadedTemplateId, reportId]); }, [reportId]);
useEffect(() => { useEffect(() => {
const user = storage.get<User | null>('currentUser', null); const user = storage.get<User | null>('currentUser', null);
@@ -300,6 +301,7 @@ export default function ReportEditor() {
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
document.execCommand('delete'); document.execCommand('delete');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage(); saveDraftToStorage();
} }
return; return;

View File

@@ -0,0 +1,116 @@
# 实现方案 — 2026-04-16-20-24-11
## 根因分析
当前问题由两个核心缺陷共同导致:
### 1. `saveDraftToStorage` 的闭包陷阱 + DOM 引用失效
此前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 重构为直接从 React state 读取:
```tsx
const saveDraftToStorage = React.useCallback(() => {
storage.set(key, {
content: editorRef.current?.innerHTML || '',
reportData,
videos,
capturedFrames,
...
});
}, [reportData, videos, capturedFrames, ...]);
```
这引入了新的问题:
- **闭包陷阱**:用户操作后常见写法是 `setCapturedFrames(nextFrames); saveDraftToStorage();`。由于 `setState` 异步,`saveDraftToStorage` 闭包中读取到的 `capturedFrames` 仍是旧值(空数组),导致旧值覆盖 localStorage。
- **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空,导致 `content: editorRef.current?.innerHTML || ''` 保存了空字符串,覆盖了已有的报告内容。
### 2. `contentRef` 更新遗漏
代码中部分修改编辑器 DOM 的路径没有同步更新 `contentRef.current`。例如 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef`
## 修改文件清单
| 文件 | 修改类型 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 修改 | 重构 `saveDraftToStorage` + 补齐 `contentRef` 遗漏点 |
## 具体代码变更
### 变更 1重构 `saveDraftToStorage` 从 Ref 读取
**修改为:**
```tsx
const saveDraftToStorage = React.useCallback(() => {
const user = storage.get<User | null>('currentUser', null);
const key = user ? `reportEditorDraft_${user.username}` : '';
if (key) {
const currentContent = contentRef.current || editorRef.current?.innerHTML || '';
storage.set(key, {
content: currentContent,
draftReportId: reportId || null,
reportData: stateRef.current.reportData,
videos: stateRef.current.videos,
capturedFrames: stateRef.current.capturedFrames,
activeTab: stateRef.current.activeTab,
loadedTemplateId: stateRef.current.loadedTemplateId
});
}
}, [reportId]);
```
**效果**
- 数据源回归 `stateRef``contentRef`,彻底摆脱 React state 的闭包陷阱。
- `content` 优先读取 `contentRef.current`(在内存中稳定存在),回退到 `editorRef.current?.innerHTML`(兼容某些未更新 contentRef 的旧路径),兜底为空字符串。即使组件卸载时 DOM 已被销毁,`contentRef.current` 仍保存着最新的编辑器 HTML。
### 变更 2补齐 `contentRef` 更新遗漏
`handleEditorClick``document.execCommand('delete')` 分支中,增加 `contentRef.current` 的同步:
**当前代码(约第 296-304 行):**
```tsx
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
saveDraftToStorage();
}
```
**修改为:**
```tsx
} else {
const range = document.createRange();
range.selectNode(placeholder);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('delete');
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
```
### 变更 3确保自动保存 effect 绑定最新 `saveDraftToStorage`
当前自动保存 effect 已经绑定 `[saveDraftToStorage]`,由于 `saveDraftToStorage` 的 dependency 现在只有 `[reportId]`effect 不会因为 state 变化而频繁重新注册,但 cleanup 中仍然指向最新的保存函数。
### 变更 4初始化恢复时 `contentRef` 同步(已有,确认无误)
`useEffect``useLayoutEffect` 的各恢复分支中,设置 `editorRef.current.innerHTML = draft.content`(或 `found.content`)时,代码已经同步设置了 `contentRef.current = ...`,这部分无需修改。
## 风险点
| 风险 | 级别 | 应对措施 |
|------|------|---------|
| 仍有未被发现的 `contentRef` 更新遗漏点 | 低 | 已全面搜索 `innerHTML` 修改点和 `saveDraftToStorage` 调用点,仅发现 1 处遗漏 |
| `stateRef` 在某些新功能中再次不同步 | 低 | `saveDraftToStorage``stateRef` 读取后续新增功能只要保持「setState 后立即同步 stateRef」的习惯即可 |
## 回滚策略
本次修改仅调整 `saveDraftToStorage` 的数据源和补齐一处 `contentRef` 更新,不改变数据结构和接口。如出现异常,可直接 `git revert` 回滚。
---
**⚠️ 请审核以上方案,确认无误后回复「确认」或提出修改意见,我将进入测试方案编写阶段。**

View File

@@ -0,0 +1,72 @@
# 测试方案 — 2026-04-16-20-24-11
## 测试目标
验证路由切换后,报告编辑器内容(文本、图片、表格等)和视频分析关键帧(自动/手动摘取)均不再丢失。
## 测试环境
- 浏览器Chrome / Edge
- 前置条件:已登录系统
- 测试文件:准备一个时长超过 30 秒的 MP4 视频文件
## 测试用例设计
### 用例 1新建报告 — 编辑器内容 + 基本信息切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 1.1 | 进入 `/report-editor` | 页面正常加载默认模板 |
| 1.2 | 填写患者姓名、住院号 | 输入内容保留 |
| 1.3 | 在编辑器中输入文字、插入表格 | 内容正常显示 |
| 1.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **编辑器内容和基本信息完整保留** |
### 用例 2新建报告 — 视频 + 自动/手动关键帧切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 2.1 | 上传视频 | 视频出现在右侧列表 |
| 2.2 | 点击「自动关键帧摘取」 | 右侧出现多张关键帧 |
| 2.3 | 手动截取 2 张截图 | 手动截图出现在右侧 |
| 2.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **视频列表、自动关键帧、手动截图全部保留** |
### 用例 3新建报告 — placeholder 图片 + 删除 placeholder 后切换路由
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 3.1 | 拖拽一张关键帧到 `image-placeholder` | placeholder 显示图片 |
| 3.2 | 点击 placeholder 的 × 删除图片(保留空 placeholder | placeholder 恢复为空 |
| 3.3 | 再次拖拽一张手动截图到 placeholder | 再次显示图片 |
| 3.4 | 跳转到 `/report-manage`,再返回 `/report-editor` | **placeholder 中的图片保留,右侧关键帧列表也保留** |
### 用例 4编辑已有报告 — 修改后保存并重新编辑
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 4.1 | 编辑已有报告 | 数据正常加载 |
| 4.2 | 修改内容并保存草稿 | 提示保存成功 |
| 4.3 | 离开并重新进入编辑 | **所有修改完整恢复** |
### 用例 5边界 — 多次快速切换
| 步骤 | 操作 | 预期结果 |
|------|------|---------|
| 5.1 | 完成用例 1+2 的操作 | 数据正常 |
| 5.2 | 连续快速切换路由 3 次以上 | **没有任何数据丢失** |
| 5.3 | 检查 localStorage draft | `content``videos``capturedFrames` 均非空 |
## 验收标准
- [ ] 编辑器内容在路由切换后 100% 保留
- [ ] 基本信息在路由切换后 100% 保留
- [ ] 视频和关键帧在路由切换后 100% 保留
- [ ] 多次快速切换后数据不丢失
- [ ] 编辑已有报告保存后重新编辑数据完整
## 测试方式
手工浏览器验证。
---
**⚠️ 请审核以上测试方案,确认无误后回复「确认」或提出修改意见,我将进入最终执行阶段。**

View File

@@ -178,3 +178,31 @@
- 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。 - 自动保存函数应直接从最新的 React state 和 DOM 读取数据,通过 `useCallback` + 完整的 dependency 数组保证闭包始终新鲜。
- 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。 - 在开发阶段应始终开启 `StrictMode` 测试,因为它能暴露 ref-based 状态同步在卸载/重挂载时的隐藏 bug。
- 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。 - 对于大型表单/编辑器组件,应将自动保存逻辑与业务状态彻底解耦,统一通过 hook 的最新状态闭包来持久化。
---
## 记录 9编辑器内容和关键帧在路由切换后仍然丢失——从 Ref 读取避免闭包陷阱和 DOM 失效
**A. 具体问题**
`/report-editor` 中编辑报告(输入文字、上传视频、自动/手动摘取关键帧、拖拽图片到 placeholder切换到 `/report-manage` 再返回 `/report-editor`
- `class="editor-content-wrapper print-wrapper"` 中的报告内容全部丢失;
- 视频分析面板中的自动关键帧和手动截图全部丢失。
**B. 产生问题原因**
1. **闭包陷阱**:之前为修复 `stateRef` 不同步的问题,将 `saveDraftToStorage` 改为直接从 React state`capturedFrames``videos`)读取。但代码中大量存在 `setCapturedFrames(nextFrames); saveDraftToStorage();` 的写法。由于 `setState` 是异步的,`saveDraftToStorage` 闭包中读到的 `capturedFrames` 仍然是旧值(空数组),导致旧值覆盖了 localStorage 中的有效 draft。
2. **卸载时 DOM 失效**:组件卸载时 React 开始销毁 DOM 树,`editorRef.current` 可能已经变为 `null` 或其 `innerHTML` 已为空。`content: editorRef.current?.innerHTML || ''` 会把空字符串保存到 draft 中,导致报告内容丢失。
3. **`contentRef` 更新遗漏**:在 `handleEditorClick` 中通过 `document.execCommand('delete')` 删除 placeholder 后,直接调用了 `saveDraftToStorage()`,但没有先更新 `contentRef.current`,进一步加剧了内容不一致。
**C. 解决问题方案**
1. **重构 `saveDraftToStorage` 从 Ref 读取**
- `content` 优先读取 `contentRef.current`(内存引用,卸载时仍稳定存在),回退到 `editorRef.current?.innerHTML`
- `reportData``videos``capturedFrames``activeTab``loadedTemplateId` 全部从 `stateRef.current` 读取,彻底避开 React state 的闭包陷阱。
- `useCallback` 的 dependency 仅保留 `[reportId]`,避免因 state 变化产生陈旧闭包。
2. **补齐 `contentRef` 遗漏**:在 `handleEditorClick``document.execCommand('delete')` 分支后,增加 `if (editorRef.current) contentRef.current = editorRef.current.innerHTML;`,确保 DOM 修改后 `contentRef` 及时同步。
**D. 后续如何避免问题**
- 对于需要在异步操作或组件卸载时读取的"最新状态"**应优先使用 `useRef` 作为稳定的数据快照**,而不是依赖 React state 的闭包。
- 自动保存函数的 `useCallback` dependency 应尽量精简(如只保留 `reportId`),避免因 state 变化导致闭包更新不同步。
- 任何直接操作 DOM 修改编辑器内容的代码,都必须**紧跟一行 `contentRef.current = editorRef.current.innerHTML`**,确保内存中的内容快照与 DOM 保持一致。
- 在开发阶段应定期测试「组件卸载 → 重新挂载」的场景React 18 `StrictMode` 会自动模拟),提前暴露闭包和 ref 同步问题。

View File

@@ -0,0 +1,34 @@
# 需求分析 — 2026-04-16-20-24-11
## 原始需求摘要
`/report-editor` 页面操作后,切换到 `/report-manage` 等其他页面,再次返回 `/report-editor` 时:
- `class="editor-content-wrapper print-wrapper"` 中的内容(报告文本、图片、表格等)全部丢失;
- 视频分析面板中自动摘取的关键帧、手动摘取的关键帧全部丢失。
此前三次修复尝试(同步 stateRef 到更多恢复分支、彻底重构 saveDraftToStorage 依赖 React state未能根治问题。
## 需求拆解
### 功能点
- 修复路由切换后报告编辑器内容丢失的问题;
- 修复路由切换后自动/手动关键帧丢失的问题;
- 修复 `saveDraftToStorage` 中的闭包陷阱问题;
- 修复组件卸载时 `editorRef` 失效导致的 content 丢失问题;
- 确保所有修改编辑器 DOM 的操作后都及时更新 `contentRef`
### 非功能点
- 最小化改动范围,不引入新的状态管理库;
- 保持现有 localStorage 草稿机制不变;
- 保持用户现有的操作习惯(上传视频、自动摘帧、拖拽插入等)。
## 影响范围预估
| 模块 | 影响程度 | 说明 |
|------|---------|------|
| `src/pages/ReportEditor.tsx` | 高 | `saveDraftToStorage` 函数重构 + `contentRef` 遗漏点补齐 |
| 其他文件 | 无 | 不涉及修改 |
## 待确认问题
无。问题现象和分析已明确。